From 3b7d3d1ecb82c0d56af577aa68d7937725c54eed Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 6 Aug 2026 11:29:29 +0200 Subject: [PATCH 01/29] Fix unknown-docs site name when --path points to docs subfolder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running `docs-builder serve --path ~/Projects/docs-eng-team/docs/`, FindGitRoot ceiling correctly stops at rootFolder (the docs folder), so DocumentationCheckoutDirectory is null for local direct-path invocations. Previously the Name fallback was `unknown-{sourceDir.Name}` (e.g. unknown-docs). The fix: fall back to the parent directory name, which is the repository clone directory — giving the expected name (e.g. docs-eng-team) without touching the intentional null-ceiling behavior that tests validate. Double-chevron: island entries in the parent nav show >> (icon-chevron-double-down in the SVG sprite, rotated right by nav-chevron CSS) to signal they open a sub-navigation rather than expand an inline subtree. NavigationRenderNodeKind: add Island as a dedicated kind so the template has a clean three-way branch (Leaf / Island / Node) instead of a Node kind with a separate IsIslandListing boolean. Co-Authored-By: Claude Sonnet 4.6 --- src/Elastic.Documentation.Configuration/BuildContext.cs | 6 +++++- src/Elastic.Markdown/IO/DocumentationSet.cs | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 1d0a285bda..c4b8b50b3f 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -128,7 +128,11 @@ public BuildContext( if (ConfigurationPath.FullName != DocumentationSourceDirectory.FullName) DocumentationSourceDirectory = ConfigurationPath.Directory!; - Git = gitCheckoutInformation ?? GitCheckoutInformationFactory.Create(DocumentationCheckoutDirectory, ReadFileSystem); + // When DocumentationCheckoutDirectory is null (source is the docs subfolder, not the repo root), + // try the parent directory as the git root so branch/commit info is still available locally. + var gitRoot = DocumentationCheckoutDirectory + ?? (DocumentationSourceDirectory.Parent is { } p ? Paths.FindGitRoot(p) : null); + Git = gitCheckoutInformation ?? GitCheckoutInformationFactory.Create(gitRoot, ReadFileSystem); // Load and resolve the docset file, or create an empty one if it doesn't exist ConfigurationYaml = ConfigurationPath.Exists diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index eb6316d614..e155040cc3 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -91,7 +91,9 @@ public DocumentationSet( Name = Context.Git != GitCheckoutInformation.Unavailable ? Context.Git.RepositoryName - : Context.DocumentationCheckoutDirectory?.Name ?? $"unknown-{Context.DocumentationSourceDirectory.Name}"; + : Context.DocumentationCheckoutDirectory?.Name + ?? Context.DocumentationSourceDirectory.Parent?.Name + ?? Context.DocumentationSourceDirectory.Name; OutputStateFile = OutputDirectory.FileSystem.FileInfo.New(Path.Join(OutputDirectory.FullName, ".doc.state")); LinkReferenceFile = OutputDirectory.FileSystem.FileInfo.New(Path.Join(OutputDirectory.FullName, "links.json")); From a6a07f6d0f5dc991004fa163b1c2846140c01594 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 6 Aug 2026 17:12:11 +0200 Subject: [PATCH 02/29] Bootstrap path resolution: FindGitRoot maxParents, fix .git scope, testable git checkout Replace the broken ceiling-based git-root walk with a maxParents bound anchored at the docset directory, making --path repo/ and --path repo/docs/ resolve to the same DocumentationCheckoutDirectory. Fix GitCheckoutInformationFactory to stop escaping ScopedFileSystem, use TryReadGitDirPointer (handling commondir), and never emit a random GUID as a git ref. Move the #if DEBUG *.slnx relaxation into FindGitRoot where the depth policy belongs. Changes: - Paths.FindGitRoot(IDirectoryInfo, int maxParents=1): replace ceiling param with maxParents so the walk is always bounded, docset-anchored, and the DEBUG *.slnx relaxation lives in one place - Paths.TryReadGitDirPointer: single implementation of gitdir parsing that handles StartsWith("gitdir:"), relative paths resolved against the .git file directory (not CWD), and commondir following for nested worktrees - GitCheckoutInformationFactory: use TryReadGitDirPointer, route all probes through guarded fileSystem.File/Directory.Exists members, eliminate fakeRef (failed HEAD -> Unavailable), invert mock shortcircuit (attempt real resolution first; canned fallback only when .git is absent or a bare dir with no config) - BuildContext: FindGitRoot(DocumentationSourceDirectory, maxParents:1) instead of ceiling:rootFolder; removes gitRoot ?? .Parent fallback added in previous commit - FindGitRootTests: port ceiling: cases to maxParents:; add WorktreeGitFile_OneLevel - BuildContextDocumentationCheckoutDirectoryTests: SourceAsDocsSubtreeOnly now asserts CheckoutDirectory == repoPath (was null); add PathAndDocsSubfolder_ResolveIdenticalCheckout - GitCheckoutResolutionTests: new suite covering regular repo, detached HEAD, worktree with absolute/relative gitdir, commondir, missing gitdir, canned fallback Co-Authored-By: Claude Sonnet 4.6 --- .../BuildContext.cs | 12 +- .../GitCheckoutInformationFactory.cs | 151 +++++++----- src/Elastic.Documentation.Tooling/Paths.cs | 105 +++++--- .../FindGitRootTests.cs | 76 +++--- .../GitCheckoutResolutionTests.cs | 228 ++++++++++++++++++ ...textDocumentationCheckoutDirectoryTests.cs | 63 ++++- 6 files changed, 507 insertions(+), 128 deletions(-) create mode 100644 tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index c4b8b50b3f..a23420db2e 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -119,7 +119,11 @@ public BuildContext( ? (configurationFile.Directory!, configurationFile) : Paths.FindDocsFolderFromRoot(ReadFileSystem, rootFolder); - DocumentationCheckoutDirectory = Paths.FindGitRoot(DocumentationSourceDirectory, ceiling: rootFolder); + // Walk at most one parent above the docset anchor so that both + // --path repo/ (docset anchor = repo/docs, .git at repo — 1 parent) + // --path repo/docs/ (docset anchor = repo/docs, .git at repo — 1 parent) + // resolve to the same CheckoutDirectory, without ever escaping the scope root. + DocumentationCheckoutDirectory = Paths.FindGitRoot(DocumentationSourceDirectory, maxParents: 1); OutputDirectory = !string.IsNullOrWhiteSpace(output) ? WriteFileSystem.DirectoryInfo.New(output) @@ -128,11 +132,7 @@ public BuildContext( if (ConfigurationPath.FullName != DocumentationSourceDirectory.FullName) DocumentationSourceDirectory = ConfigurationPath.Directory!; - // When DocumentationCheckoutDirectory is null (source is the docs subfolder, not the repo root), - // try the parent directory as the git root so branch/commit info is still available locally. - var gitRoot = DocumentationCheckoutDirectory - ?? (DocumentationSourceDirectory.Parent is { } p ? Paths.FindGitRoot(p) : null); - Git = gitCheckoutInformation ?? GitCheckoutInformationFactory.Create(gitRoot, ReadFileSystem); + Git = gitCheckoutInformation ?? GitCheckoutInformationFactory.Create(DocumentationCheckoutDirectory, ReadFileSystem); // Load and resolve the docset file, or create an empty one if it doesn't exist ConfigurationYaml = ConfigurationPath.Exists diff --git a/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs b/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs index b5875c25c8..bc6975addd 100644 --- a/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs +++ b/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using System.Text.RegularExpressions; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Extensions; using Microsoft.Extensions.Logging; using Nullean.ScopedFileSystem; @@ -19,57 +20,103 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem if (source is null) return GitCheckoutInformation.Unavailable; - // Return test data for in-memory (mock) file systems. Use ScopedFileSystem.InnerType - // (available since Nullean.ScopedFileSystem 0.4.0) to inspect through the scope wrapper - // rather than relying on the outer type name. - var fsType = fileSystem is ScopedFileSystem sf ? sf.InnerType : fileSystem.GetType(); - if (fsType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) + var result = TryCreate(source, fileSystem, logger); + + // Fall back to canned test data only when the inner filesystem is a mock AND no .git entry + // exists at all at the source. This preserves back-compat for tests that seed no git layout + // (they get the well-known canned instance), while tests that seed a .git file or directory + // — even one that fails to resolve — receive the real result (Unavailable). + // Use ScopedFileSystem.InnerType (available since Nullean.ScopedFileSystem 0.4.0) to inspect + // through the scope wrapper rather than relying on the outer type name. + if (result == GitCheckoutInformation.Unavailable) { - return new GitCheckoutInformation + var fsType = fileSystem is ScopedFileSystem sf ? sf.InnerType : fileSystem.GetType(); + if (fsType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) { - Branch = $"test-e35fcb27-5f60-4e", - Remote = "elastic/docs-builder", - Ref = "e35fcb27-5f60-4e", - RepositoryName = "docs-builder" - }; + // Fall back to canned data for mocks in two cases that are not "real layout" attempts: + // - No .git entry at all (tests that don't model git) + // - .git is a directory without a config (tests that seed .git only so FindGitRoot + // can set DocumentationCheckoutDirectory but don't need real git info) + // Do NOT fall back when .git is a FILE (worktree pointer) — those tests are + // explicitly modeling a worktree layout and expect real or Unavailable results. + var gitPath = fileSystem.Path.Join(source.FullName, ".git"); + var noGitEntry = !fileSystem.Directory.Exists(gitPath) && !fileSystem.File.Exists(gitPath); + var gitDirWithoutConfig = fileSystem.Directory.Exists(gitPath) + && !fileSystem.File.Exists(fileSystem.Path.Join(gitPath, "config")); + if (noGitEntry || gitDirWithoutConfig) + { + return new GitCheckoutInformation + { + Branch = "test-e35fcb27-5f60-4e", + Remote = "elastic/docs-builder", + Ref = "e35fcb27-5f60-4e", + RepositoryName = "docs-builder" + }; + } + } } - var fakeRef = Guid.NewGuid().ToString()[..16]; - var gitDir = GitDir(source, ".git"); - if (!gitDir.Exists) + return result; + } + + private static GitCheckoutInformation TryCreate(IDirectoryInfo source, IFileSystem fileSystem, ILogger? logger) + { + // Resolve the actual .git directory. For regular repos this is source/.git/; + // for worktrees source/.git is a file pointing to the real git dir. + IDirectoryInfo gitDir; + var gitDirPath = fileSystem.Path.Join(source.FullName, ".git"); + + if (fileSystem.Directory.Exists(gitDirPath)) { - var worktreeFile = Git(source, ".git"); - if (!worktreeFile.Exists) - return GitCheckoutInformation.Unavailable; - var workTreePath = Read(source, ".git")?.Replace("gitdir: ", string.Empty); - if (workTreePath is null) - return GitCheckoutInformation.Unavailable; - gitDir = fileSystem.DirectoryInfo.New(workTreePath).GetParent(".git"); - if (gitDir is null || !gitDir.Exists) + gitDir = fileSystem.DirectoryInfo.New(gitDirPath); + } + else + { + var gitFile = fileSystem.FileInfo.New(gitDirPath); + if (!Paths.TryReadGitDirPointer(fileSystem, gitFile, out var resolvedGitDir) + || resolvedGitDir is null) return GitCheckoutInformation.Unavailable; + + gitDir = resolvedGitDir; } - var gitConfig = Git(gitDir, "config"); - if (!gitConfig.Exists) + var gitConfigPath = fileSystem.Path.Join(gitDir.FullName, "config"); + if (!fileSystem.File.Exists(gitConfigPath)) { logger?.LogInformation("Git checkout information not available."); return GitCheckoutInformation.Unavailable; } - var head = Read(gitDir, "HEAD") ?? fakeRef; - var gitRef = head; - var branch = head.Replace("refs/heads/", string.Empty); - if (head.StartsWith("ref:", StringComparison.OrdinalIgnoreCase)) + var headPath = fileSystem.Path.Join(gitDir.FullName, "HEAD"); + var headText = fileSystem.File.Exists(headPath) + ? fileSystem.File.ReadAllText(headPath).Trim() + : null; + + if (headText is null) + return GitCheckoutInformation.Unavailable; + + string gitRef; + string branch; + if (headText.StartsWith("ref:", StringComparison.OrdinalIgnoreCase)) { - head = head.Replace("ref: ", string.Empty); - gitRef = Read(gitDir, head) ?? fakeRef; - branch = branch.Replace("ref: ", string.Empty); + var refPath = headText["ref:".Length..].Trim(); + branch = refPath.Replace("refs/heads/", string.Empty); + var refFilePath = fileSystem.Path.Join(gitDir.FullName, refPath.Replace('/', fileSystem.Path.DirectorySeparatorChar)); + gitRef = fileSystem.File.Exists(refFilePath) + ? fileSystem.File.ReadAllText(refFilePath).Trim() + : headText; // symbolic ref not yet written (new empty repo) — use the ref name itself } else - branch = Environment.GetEnvironmentVariable("GITHUB_PR_REF_NAME") ?? Environment.GetEnvironmentVariable("GITHUB_REF_NAME") ?? "detached/head"; + { + // Detached HEAD: raw SHA + gitRef = headText; + branch = Environment.GetEnvironmentVariable("GITHUB_PR_REF_NAME") + ?? Environment.GetEnvironmentVariable("GITHUB_REF_NAME") + ?? "detached/head"; + } var ini = new IniFile(); - using var stream = gitConfig.OpenRead(); + using var stream = fileSystem.File.OpenRead(gitConfigPath); using var streamReader = new StreamReader(stream); ini.Load(streamReader); @@ -113,35 +160,21 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem logger?.LogInformation("-> Remote Name: {GitRemote}", info.Remote); logger?.LogInformation("-> Repository Name: {RepositoryName}", info.RepositoryName); return info; + } - IFileInfo Git(IDirectoryInfo directoryInfo, string path) => - fileSystem.FileInfo.New(Path.Join(directoryInfo.FullName, path)); - - IDirectoryInfo GitDir(IDirectoryInfo directoryInfo, string path) => - fileSystem.DirectoryInfo.New(Path.Join(directoryInfo.FullName, path)); - - string? Read(IDirectoryInfo directoryInfo, string path) - { - var gitPath = Git(directoryInfo, path).FullName; - return !fileSystem.File.Exists(gitPath) - ? null - : fileSystem.File.ReadAllText(gitPath).Trim(Environment.NewLine.ToCharArray()); - } - - string BranchTrackingRemote(string b, IniFile c) - { - var sections = c.GetSections(); - var branchSection = $"branch \"{b}\""; - if (!sections.Contains(branchSection)) - return string.Empty; - - var remoteName = ini.GetSetting(branchSection, "remote")?.Trim(); + private static string BranchTrackingRemote(string branch, IniFile config) + { + var sections = config.GetSections(); + var branchSection = $"branch \"{branch}\""; + if (!sections.Contains(branchSection)) + return string.Empty; - var remoteSection = $"remote \"{remoteName}\""; + var remoteName = config.GetSetting(branchSection, "remote")?.Trim(); + if (string.IsNullOrEmpty(remoteName)) + return string.Empty; - remote = ini.GetSetting(remoteSection, "url")?.Trim(); - return remote ?? string.Empty; - } + var remoteSection = $"remote \"{remoteName}\""; + return config.GetSetting(remoteSection, "url")?.Trim() ?? string.Empty; } [GeneratedRegex(@"\.git$", RegexOptions.IgnoreCase)] diff --git a/src/Elastic.Documentation.Tooling/Paths.cs b/src/Elastic.Documentation.Tooling/Paths.cs index a3d7011301..fd374b69e0 100644 --- a/src/Elastic.Documentation.Tooling/Paths.cs +++ b/src/Elastic.Documentation.Tooling/Paths.cs @@ -22,10 +22,10 @@ public static class Paths /// itself when no git root is found within the allowed depth. /// /// - /// Depth protection: in release builds the .git anchor must be at most 1 directory - /// above — documentation is not expected to live deep inside - /// a repo. In debug builds a deeper .git is accepted when a *.slnx file is - /// adjacent (developer running the binary from an IDE output directory). + /// Uses raw and is therefore only appropriate for the initial + /// scope-building bootstrap (before a + /// can be constructed). Callers that already have an should use + /// instead. /// public static string FindGitRoot(string startPath) { @@ -33,7 +33,7 @@ public static string FindGitRoot(string startPath) var dir = Directory.Exists(resolved) ? new DirectoryInfo(resolved) : new DirectoryInfo(Path.GetDirectoryName(resolved) ?? resolved); - var startDir = dir.FullName; // always a directory, used as fallback + var startDir = dir.FullName; var depth = 0; while (dir != null) { @@ -47,7 +47,6 @@ public static string FindGitRoot(string startPath) if (depth <= 1) return dir.FullName; #endif - // .git found but too deep — stop searching return startDir; } depth++; @@ -61,42 +60,40 @@ public static string FindGitRoot(string startPath) /// a .git directory or file (worktree pointer) is found. /// Returns if no git root is found within the allowed depth. /// - /// Directory to start the upward search from. - /// - /// Optional upper bound for the search. When provided, the walk may reach - /// but never goes above it, replacing the fixed depth limit with a directory boundary. - /// When , the original depth-1 limit applies. + /// Directory to start the upward search from (typically the docset anchor). + /// + /// Maximum number of parent directories to walk above + /// (default: 1, i.e. self or one parent). The depth is 0-based: at depth 0 we check + /// itself; at depth 1 its immediate parent, and so on. /// /// - /// Without a ceiling the same depth protection as applies. - /// With a ceiling the caller guarantees the boundary is trustworthy (e.g. the working directory - /// root), so any .git found at or below it is accepted regardless of depth. + /// In DEBUG builds a .git found beyond is still accepted + /// when it has an adjacent *.slnx file — this covers the developer case of running a + /// binary from an IDE output directory (e.g. bin/Debug/net10.0/) where the solution + /// root is several levels up. /// - public static IDirectoryInfo? FindGitRoot(IDirectoryInfo startDirectory, IDirectoryInfo? ceiling = null) + public static IDirectoryInfo? FindGitRoot(IDirectoryInfo startDirectory, int maxParents = 1) { var directory = startDirectory; var depth = 0; while (directory != null) { - if (ceiling is not null && !directory.IsSubPathOf(ceiling)) - return null; - var hasGit = directory.GetDirectories(".git").Length > 0 || directory.GetFiles(".git").Length > 0; if (hasGit) { - if (ceiling is not null) - return directory; #if DEBUG - if (depth <= 1 || directory.GetFiles("*.slnx").Length > 0) + if (depth <= maxParents || directory.GetFiles("*.slnx").Length > 0) return directory; #else - if (depth <= 1) + if (depth <= maxParents) return directory; #endif - // .git found but too deep + // .git found but too deep — stop searching return null; } + if (depth >= maxParents) + return null; depth++; directory = directory.Parent; } @@ -120,15 +117,8 @@ private static DirectoryInfo DetermineWorkingDirectoryRoot() || directory.GetFiles(".git").Length > 0; if (hasGit) { - // Only accept .git beyond 1 level up in debug when a *.slnx is adjacent - // (developer running from IDE output directory such as bin/Debug/net10.0/). -#if DEBUG - if (depth <= 1 || directory.GetFiles("*.slnx").Length > 0) - return directory; -#else if (depth <= 1) return directory; -#endif // .git found but too deep — stop without adopting it return cwd; } @@ -215,6 +205,61 @@ public static (IDirectoryInfo, IFileInfo) FindDocsFolderFromRoot(IFileSystem rea return (docsFolder, configurationPath); } + /// + /// Resolves the real git directory from a worktree pointer (.git file containing + /// gitdir: <path>). Handles both absolute and relative gitdir paths, and follows + /// commondir to the shared object store when present (linked/nested worktree). + /// + /// The filesystem to read through. + /// The .git file (worktree pointer) to read. + /// + /// On success, the resolved git directory (.git/ or the worktrees subdirectory's + /// parent when a commondir is present). + /// + /// when the pointer was read and resolved; + /// when the file is absent, malformed, or the resolved path does not exist. + public static bool TryReadGitDirPointer(IFileSystem fileSystem, IFileInfo gitFile, out IDirectoryInfo? gitDir) + { + gitDir = null; + if (!fileSystem.File.Exists(gitFile.FullName)) + return false; + + var text = fileSystem.File.ReadAllText(gitFile.FullName); + var firstLineBreak = text.IndexOfAny(['\r', '\n']); + var firstLine = (firstLineBreak >= 0 ? text[..firstLineBreak] : text).Trim(); + if (!firstLine.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase)) + return false; + + var rawGitDir = firstLine["gitdir:".Length..].Trim(); + if (string.IsNullOrEmpty(rawGitDir)) + return false; + + // Resolve relative paths against the directory that contains the .git file + var containingDir = gitFile.Directory?.FullName ?? string.Empty; + var resolvedGitDir = fileSystem.Path.IsPathFullyQualified(rawGitDir) + ? rawGitDir + : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(containingDir, rawGitDir)); + + if (!fileSystem.Directory.Exists(resolvedGitDir)) + return false; + + // Follow commondir to reach the shared .git root (linked/nested worktrees) + var commonDirFile = fileSystem.Path.Combine(resolvedGitDir, "commondir"); + if (fileSystem.File.Exists(commonDirFile)) + { + var commonDirRelative = fileSystem.File.ReadAllText(commonDirFile).Trim(); + var commonDir = fileSystem.Path.IsPathFullyQualified(commonDirRelative) + ? commonDirRelative + : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(resolvedGitDir, commonDirRelative)); + + if (fileSystem.Directory.Exists(commonDir)) + resolvedGitDir = commonDir; + } + + gitDir = fileSystem.DirectoryInfo.New(resolvedGitDir); + return true; + } + /// Validates that is a single path segment with no separators or traversal components. /// Throws when the value is blank, contains separators, or equals "." / "..". public static void ValidateSinglePathSegment(string value, string paramName) diff --git a/tests/Elastic.Documentation.Configuration.Tests/FindGitRootTests.cs b/tests/Elastic.Documentation.Configuration.Tests/FindGitRootTests.cs index 65ec14db9a..63b4d586f9 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/FindGitRootTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/FindGitRootTests.cs @@ -17,12 +17,11 @@ public void DocsAtRoot_FindsGitRoot() fs.AddFile("/repo/docset.yml", new("toc: []")); var start = fs.DirectoryInfo.New("/repo"); - var expected = start.FullName; - var result = Paths.FindGitRoot(start, ceiling: start); + var result = Paths.FindGitRoot(start); result.Should().NotBeNull(); - result.FullName.Should().Be(expected); + result.FullName.Should().Be(start.FullName); } [Fact] @@ -32,13 +31,14 @@ public void DocsInDocsFolder_FindsGitRoot() fs.AddDirectory("/repo/.git"); fs.AddFile("/repo/docs/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/repo"); var start = fs.DirectoryInfo.New("/repo/docs"); + var expected = fs.DirectoryInfo.New("/repo"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + // Default maxParents=1: docset is one level below checkout, so .git is found at depth 1 + var result = Paths.FindGitRoot(start); result.Should().NotBeNull(); - result.FullName.Should().Be(ceiling.FullName); + result.FullName.Should().Be(expected.FullName); } [Fact] @@ -48,17 +48,18 @@ public void DocsNestedTwoLevels_FindsGitRoot() fs.AddDirectory("/repo/.git"); fs.AddFile("/repo/docs/resilience-team/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/repo"); var start = fs.DirectoryInfo.New("/repo/docs/resilience-team"); + var expected = fs.DirectoryInfo.New("/repo"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + // maxParents=2: docset is two levels below checkout + var result = Paths.FindGitRoot(start, maxParents: 2); result.Should().NotBeNull(); - result.FullName.Should().Be(ceiling.FullName); + result.FullName.Should().Be(expected.FullName); } [Fact] - public void DocsNestedTwoLevels_WithoutCeiling_ReturnsNull() + public void DocsNestedTwoLevels_WithDefaultMaxParents_ReturnsNull() { var fs = new MockFileSystem(); fs.AddDirectory("/repo/.git"); @@ -66,58 +67,60 @@ public void DocsNestedTwoLevels_WithoutCeiling_ReturnsNull() var start = fs.DirectoryInfo.New("/repo/docs/resilience-team"); + // Default maxParents=1 cannot reach .git at depth 2 var result = Paths.FindGitRoot(start); result.Should().BeNull(); } [Fact] - public void CeilingPreventsEscapingToParentRepo() + public void BoundPreventsEscapingToParentRepo() { var fs = new MockFileSystem(); fs.AddDirectory("/parent-repo/.git"); fs.AddDirectory("/parent-repo/checkout"); fs.AddFile("/parent-repo/checkout/docs/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/parent-repo/checkout"); var start = fs.DirectoryInfo.New("/parent-repo/checkout/docs"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + // maxParents=1: checks checkout/docs (depth 0) and checkout (depth 1), neither has .git + var result = Paths.FindGitRoot(start); - result.Should().BeNull("the .git is above the ceiling and must not be reached"); + result.Should().BeNull("the .git is two levels above the anchor, beyond maxParents"); } [Fact] - public void CeilingPreventsEscapingToParentRepo_DeeplyNested() + public void BoundPreventsEscapingToParentRepo_DeeplyNested() { var fs = new MockFileSystem(); fs.AddDirectory("/workspace/projects/other-repo/.git"); fs.AddDirectory("/workspace/projects/other-repo/subrepo/docs/team"); fs.AddFile("/workspace/projects/other-repo/subrepo/docs/team/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/workspace/projects/other-repo/subrepo"); var start = fs.DirectoryInfo.New("/workspace/projects/other-repo/subrepo/docs/team"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + // Default maxParents=1: cannot reach .git three levels up + var result = Paths.FindGitRoot(start); - result.Should().BeNull("the .git belongs to a parent repo outside the ceiling"); + result.Should().BeNull("the .git belongs to a parent repo outside the allowed depth"); } [Fact] - public void GitRootInsideCeiling_IsAccepted() + public void GitRootDeepAboveAnchor_AcceptedWithLargeMaxParents() { var fs = new MockFileSystem(); fs.AddDirectory("/workspace/.git"); fs.AddDirectory("/workspace/docs/a/b/c"); fs.AddFile("/workspace/docs/a/b/c/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/workspace"); var start = fs.DirectoryInfo.New("/workspace/docs/a/b/c"); + var expected = fs.DirectoryInfo.New("/workspace"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + // Must pass maxParents=4 since the anchor is 4 levels below the checkout + var result = Paths.FindGitRoot(start, maxParents: 4); result.Should().NotBeNull(); - result.FullName.Should().Be(ceiling.FullName); + result.FullName.Should().Be(expected.FullName); } [Fact] @@ -127,27 +130,44 @@ public void NoGitDirectory_ReturnsNull() fs.AddDirectory("/repo/docs"); fs.AddFile("/repo/docs/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/repo"); var start = fs.DirectoryInfo.New("/repo/docs"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + var result = Paths.FindGitRoot(start); result.Should().BeNull(); } [Fact] - public void WorktreeGitFile_InsideCeiling_IsAccepted() + public void WorktreeGitFile_FindsGitRoot() { var fs = new MockFileSystem(); fs.AddFile("/repo/.git", new("gitdir: /main/.git/worktrees/repo")); fs.AddFile("/repo/docs/team/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/repo"); var start = fs.DirectoryInfo.New("/repo/docs/team"); + var expected = fs.DirectoryInfo.New("/repo"); + + // .git file (worktree pointer) counts the same as a .git dir; depth 2 needs maxParents=2 + var result = Paths.FindGitRoot(start, maxParents: 2); + + result.Should().NotBeNull(); + result.FullName.Should().Be(expected.FullName); + } - var result = Paths.FindGitRoot(start, ceiling: ceiling); + [Fact] + public void WorktreeGitFile_OneLevel_FindsGitRoot() + { + var fs = new MockFileSystem(); + fs.AddFile("/repo/.git", new("gitdir: /main/.git/worktrees/repo")); + fs.AddFile("/repo/docs/docset.yml", new("toc: []")); + + var start = fs.DirectoryInfo.New("/repo/docs"); + var expected = fs.DirectoryInfo.New("/repo"); + + // Docset one level below worktree root: found with default maxParents=1 + var result = Paths.FindGitRoot(start); result.Should().NotBeNull(); - result.FullName.Should().Be(ceiling.FullName); + result.FullName.Should().Be(expected.FullName); } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs new file mode 100644 index 0000000000..4bbfb5d7d8 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs @@ -0,0 +1,228 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; + +namespace Elastic.Documentation.Configuration.Tests; + +/// +/// Tests for driven through +/// a seeded with various git layouts. +/// Previously impossible because the mock short-circuit returned canned data for any MockFileSystem; +/// the inverted short-circuit now attempts real resolution first and only falls back to canned data +/// when resolution yields nothing. +/// +public class GitCheckoutResolutionTests +{ + private static MockFileSystem BuildFs(string root, string? branch = "main", string? sha = null, string? remote = null, bool worktree = false, string? worktreeGitDir = null) + { + var fs = new MockFileSystem(); + sha ??= "abc1234def5678"; + remote ??= "elastic/test-repo"; + + if (!worktree) + { + fs.AddDirectory($"{root}/.git"); + // HEAD + if (branch is not null) + fs.AddFile($"{root}/.git/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); + else + fs.AddFile($"{root}/.git/HEAD", new MockFileData($"{sha}\n")); // detached + // ref file + if (branch is not null) + fs.AddFile($"{root}/.git/refs/heads/{branch}", new MockFileData($"{sha}\n")); + // config + fs.AddFile($"{root}/.git/config", new MockFileData($""" + [core] + repositoryformatversion = 0 + [remote "origin"] + url = https://github.com/{remote}.git + [branch "{branch ?? "main"}"] + remote = origin + merge = refs/heads/{branch ?? "main"} + """)); + } + else + { + // Worktree: .git is a file pointing to the real git dir + var realGitDir = worktreeGitDir ?? $"/main-repo/.git/worktrees/branch"; + fs.AddFile($"{root}/.git", new MockFileData($"gitdir: {realGitDir}\n")); + // The real .git directory + fs.AddDirectory(realGitDir); + fs.AddFile($"{realGitDir}/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); + fs.AddFile($"{realGitDir}/refs/heads/{branch}", new MockFileData($"{sha}\n")); + fs.AddFile($"{realGitDir}/config", new MockFileData($""" + [core] + repositoryformatversion = 0 + [remote "origin"] + url = https://github.com/{remote}.git + [branch "{branch}"] + remote = origin + merge = refs/heads/{branch} + """)); + } + + return fs; + } + + [Fact] + public void RegularRepo_ReturnsGitInfo() + { + var fs = BuildFs("/repo", branch: "feature/my-branch", sha: "deadbeef1234"); + + var checkout = fs.DirectoryInfo.New("/repo"); + var scoped = FileSystemFactory.ScopeSourceDirectory(fs, "/repo"); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeTrue(); + result.Branch.Should().Be("feature/my-branch"); + result.Ref.Should().Be("deadbeef1234"); + // Remote is the full config url with .git suffix stripped; RepositoryName is the last segment + result.Remote.Should().EndWith("/elastic/test-repo"); + result.RepositoryName.Should().Be("test-repo"); + } + + [Fact] + public void RegularRepo_DetachedHead_NeverReturnsRandomGuid() + { + var fs = BuildFs("/repo", branch: null, sha: "cafebabe9876"); + + var checkout = fs.DirectoryInfo.New("/repo"); + var scoped = FileSystemFactory.ScopeSourceDirectory(fs, "/repo"); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeTrue(); + result.Ref.Should().Be("cafebabe9876", "detached HEAD must use the actual SHA, never a random GUID"); + result.Branch.Should().BeOneOf("detached/head"); + } + + [Fact] + public void WorktreeWithAbsoluteGitDir_ResolvesViaMainRepo() + { + var sha = "1a2b3c4d5e6f"; + var fs = BuildFs("/worktree", branch: "my-feature", sha: sha, remote: "elastic/worktree-repo", + worktree: true, worktreeGitDir: "/main-repo/.git/worktrees/my-feature"); + + // Scope must cover both the worktree dir and the main .git + var scoped = FileSystemFactory.ScopeSourceDirectory(fs, "/worktree"); + var extended = new Nullean.ScopedFileSystem.ScopedFileSystem(fs, + new Nullean.ScopedFileSystem.ScopedFileSystemOptions(["/worktree", "/main-repo/.git/worktrees/my-feature"]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }); + + var checkout = fs.DirectoryInfo.New("/worktree"); + var result = GitCheckoutInformationFactory.Create(checkout, extended); + + result.IsAvailable.Should().BeTrue(); + result.Branch.Should().Be("my-feature"); + result.Ref.Should().Be(sha); + result.RepositoryName.Should().Be("worktree-repo"); + } + + [Fact] + public void WorktreeWithRelativeGitDir_ResolvesAgainstGitFileDirectory() + { + var fs = new MockFileSystem(); + // .git file with relative gitdir — resolved relative to /worktree, NOT process CWD + fs.AddFile("/worktree/.git", new MockFileData("gitdir: ../.git/worktrees/branch\n")); + fs.AddDirectory("/.git/worktrees/branch"); + fs.AddFile("/.git/worktrees/branch/HEAD", new MockFileData("ref: refs/heads/feature\n")); + fs.AddFile("/.git/worktrees/branch/refs/heads/feature", new MockFileData("aabbccdd\n")); + fs.AddFile("/.git/worktrees/branch/config", new MockFileData(""" + [remote "origin"] + url = https://github.com/elastic/relative-test.git + [branch "feature"] + remote = origin + merge = refs/heads/feature + """)); + + var checkout = fs.DirectoryInfo.New("/worktree"); + var scoped = new Nullean.ScopedFileSystem.ScopedFileSystem(fs, + new Nullean.ScopedFileSystem.ScopedFileSystemOptions(["/worktree", "/.git"]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeTrue(); + result.Branch.Should().Be("feature"); + result.Ref.Should().Be("aabbccdd"); + result.RepositoryName.Should().Be("relative-test"); + } + + [Fact] + public void WorktreeWithCommondir_ResolvesViaCommondir() + { + var fs = new MockFileSystem(); + // .git file → worktree-specific dir → commondir → shared .git + fs.AddFile("/worktree/.git", new MockFileData("gitdir: /main/.git/worktrees/wt\n")); + fs.AddDirectory("/main/.git/worktrees/wt"); + fs.AddFile("/main/.git/worktrees/wt/commondir", new MockFileData("../..\n")); // points to /main/.git + // The shared .git directory + fs.AddDirectory("/main/.git"); + fs.AddFile("/main/.git/HEAD", new MockFileData("ref: refs/heads/topic\n")); + fs.AddFile("/main/.git/refs/heads/topic", new MockFileData("fedcba987654\n")); + fs.AddFile("/main/.git/config", new MockFileData(""" + [remote "origin"] + url = https://github.com/elastic/commondir-test.git + [branch "topic"] + remote = origin + merge = refs/heads/topic + """)); + + var checkout = fs.DirectoryInfo.New("/worktree"); + var scoped = new Nullean.ScopedFileSystem.ScopedFileSystem(fs, + new Nullean.ScopedFileSystem.ScopedFileSystemOptions(["/worktree", "/main/.git"]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeTrue(); + result.Branch.Should().Be("topic"); + result.Ref.Should().Be("fedcba987654"); + result.RepositoryName.Should().Be("commondir-test"); + } + + [Fact] + public void WorktreeMissingGitDir_ReturnsUnavailable() + { + var fs = new MockFileSystem(); + fs.AddFile("/worktree/.git", new MockFileData("gitdir: /nonexistent/.git/worktrees/wt\n")); + + var checkout = fs.DirectoryInfo.New("/worktree"); + var scoped = FileSystemFactory.ScopeSourceDirectory(fs, "/worktree"); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeFalse("a worktree pointer to a missing gitdir must yield Unavailable"); + } + + [Fact] + public void MockWithNoGitLayout_ReturnsCannedTestData() + { + // Back-compat: tests that seed no .git at all must continue to receive the canned + // test instance rather than Unavailable, so existing test suites need no churn. + var fs = new MockFileSystem(); + var checkout = fs.DirectoryInfo.New("/some/path"); + var scoped = FileSystemFactory.ScopeSourceDirectory(fs, "/some/path"); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeTrue("mock with no layout falls back to canned data"); + result.Branch.Should().Be("test-e35fcb27-5f60-4e"); + result.RepositoryName.Should().Be("docs-builder"); + } +} diff --git a/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs b/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs index d37f0b32a5..c76d0649d8 100644 --- a/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs +++ b/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs @@ -13,9 +13,17 @@ namespace Elastic.Markdown.Tests; /// -/// Regression: Codex must pass the repository clone root as source -/// so FindGitRoot(..., ceiling: rootFolder) can see .git under the ceiling (#3115). -/// Prior bug used DocsDirectory only, capping the ceiling at the docs subtree. +/// Tests that resolves correctly +/// for various --path / source combinations. +/// +/// Key invariant: --path repo/ and --path repo/docs/ must resolve to the +/// same CheckoutDirectory because the docset scan converges first (both land on +/// repo/docs/ as the anchor), and FindGitRoot then walks at most one parent. +/// +/// +/// Regression guard (#3115): Codex passes the repository clone root as source so that +/// FindGitRoot can see .git within the default maxParents range. +/// /// public class BuildContextDocumentationCheckoutDirectoryTests(ITestOutputHelper output) { @@ -47,7 +55,7 @@ public void SourceAsRepositoryRoot_SetsDocumentationCheckoutDirectory() } [Fact] - public void SourceAsDocsSubtreeOnly_LeavesDocumentationCheckoutDirectoryNull() + public void SourceAsDocsSubtree_ResolvesCheckoutFromParent() { var root = Paths.WorkingDirectoryRoot.FullName; var repoPath = Path.Combine(root, "codex-docs-only-test"); @@ -70,6 +78,51 @@ public void SourceAsDocsSubtreeOnly_LeavesDocumentationCheckoutDirectoryNull() source: docsPath, output: Path.Combine(root, "codex-docs-only-test-out")); - context.DocumentationCheckoutDirectory.Should().BeNull(); + // --path repo/docs/ now resolves the same checkout as --path repo/: + // the docset scan anchors at repo/docs/, FindGitRoot walks one parent to repo/.git + Assert.NotNull(context.DocumentationCheckoutDirectory); + context.DocumentationCheckoutDirectory.FullName.Should().Be(repoPath); + } + + [Fact] + public void PathAndDocsSubfolder_ResolveIdenticalCheckout() + { + var root = Paths.WorkingDirectoryRoot.FullName; + var repoPath = Path.Combine(root, "codex-equiv-test"); + var docsPath = Path.Combine(repoPath, "docs"); + var fs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = root }); + fs.AddDirectory(Path.Combine(repoPath, ".git")); + fs.AddFile(Path.Combine(docsPath, "docset.yml"), new MockFileData("toc: []\n")); + + var collector = new TestDiagnosticsCollector(output); + _ = collector.StartAsync(TestContext.Current.CancellationToken); + var configurationContext = TestHelpers.CreateConfigurationContext(fs); + + var contextFromRepoRoot = new BuildContext( + collector, + FileSystemFactory.ScopeCurrentWorkingDirectory(fs), + FileSystemFactory.ScopeCurrentWorkingDirectory(fs), + configurationContext, + ExportOptions.Default, + source: repoPath, + output: Path.Combine(root, "codex-equiv-test-out")); + + var contextFromDocsFolder = new BuildContext( + collector, + FileSystemFactory.ScopeCurrentWorkingDirectory(fs), + FileSystemFactory.ScopeCurrentWorkingDirectory(fs), + configurationContext, + ExportOptions.Default, + source: docsPath, + output: Path.Combine(root, "codex-equiv-test-out")); + + contextFromRepoRoot.DocumentationCheckoutDirectory.Should().NotBeNull(); + contextFromDocsFolder.DocumentationCheckoutDirectory.Should().NotBeNull(); + contextFromRepoRoot.DocumentationCheckoutDirectory.FullName + .Should().Be(contextFromDocsFolder.DocumentationCheckoutDirectory.FullName, + "--path repo/ and --path repo/docs/ must resolve to the same CheckoutDirectory"); + contextFromRepoRoot.DocumentationSourceDirectory.FullName + .Should().Be(contextFromDocsFolder.DocumentationSourceDirectory.FullName, + "--path repo/ and --path repo/docs/ must resolve to the same SourceDirectory"); } } From 4618c3332b173601c1166b1295a8af2f2ed430c1 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 6 Aug 2026 18:19:50 +0200 Subject: [PATCH 03/29] Add ResolvedDocumentationPaths record with first-principles xmldoc Documents the six-step bootstrap resolution order as the record's : 1. = --path ?? cwd 2. SourceDirectory = docset scan (the anchor) 3. CheckoutDirectory = --git-dir?.Parent ?? FindGitRoot(maxParents) ?? error (required) 4. GitDirectories = real .git dirs for scope widening (worktrees) 5. Git = GitCheckoutInformation resolved once, never re-derived 6. OutputDirectory = --output ?? /.artifacts/docs/html Co-Authored-By: Claude Sonnet 4.6 --- .../DocumentationPathsResolver.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs diff --git a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs new file mode 100644 index 0000000000..0e397778c6 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs @@ -0,0 +1,75 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; + +namespace Elastic.Documentation; + +/// +/// The resolved set of paths and git information for a documentation build or serve invocation. +/// Produced by following the bootstrap first principles: +/// +/// <path> = --path argument, or the current working directory when omitted. +/// SourceDirectory = docset scan from <path>: +/// <path>/docset.yml, <path>/_docset.yml, or any subfolder. +/// This is the anchor — all subsequent resolution is relative to it. +/// CheckoutDirectory = --git-dir?.Parent +/// ?? FindGitRoot(SourceDirectory, maxParents: N) +/// ?? error (required; use --git-dir when the heuristic cannot find .git). +/// GitDirectories = the real .git directory (or worktree commondir) +/// resolved from CheckoutDirectory. Needed to add the main repo's .git to the read scope +/// when the checkout is a worktree. +/// Git = anchored exclusively on +/// CheckoutDirectory — resolved once here, never re-derived downstream. +/// OutputDirectory = --output argument, +/// or <path>/.artifacts/docs/html. +/// +/// +public sealed record ResolvedDocumentationPaths +{ + /// + /// The invocation path — the --path argument, or the process current directory when omitted. + /// All other paths are derived from this starting point. + /// + public required IDirectoryInfo InvocationPath { get; init; } + + /// + /// The docset anchor: the directory that contains docset.yml or _docset.yml. + /// Found by scanning from via the known-location heuristic, + /// then a bounded recursive fallback. + /// + public required IDirectoryInfo SourceDirectory { get; init; } + + /// The resolved docset configuration file (docset.yml or _docset.yml). + public required IFileInfo ConfigurationPath { get; init; } + + /// + /// The repository checkout root — the directory whose immediate child is .git + /// (or whose immediate child is a worktree .git pointer file). + /// Always non-: the resolver emits a hard error when no .git + /// is found within maxParents of and no --git-dir + /// override was supplied. + /// + public required IDirectoryInfo CheckoutDirectory { get; init; } + + /// + /// The real .git directories that must be added to the read scope. For a regular + /// checkout this is [CheckoutDirectory/.git]. For a worktree this also includes the + /// main repo's .git (or the commondir target) so that git config and refs + /// can be read without a scope violation. + /// + public IReadOnlyList GitDirectories { get; init; } = []; + + /// + /// Git checkout information (branch, ref, remote, repository name) resolved from + /// . Never re-derived downstream; always set from this record. + /// + public required GitCheckoutInformation Git { get; init; } + + /// + /// The build output directory — the --output argument, or + /// /.artifacts/docs/html. + /// + public required IDirectoryInfo OutputDirectory { get; init; } +} From ecaa1ca842c6c858e932704ca0bddef4633cffac Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 6 Aug 2026 18:47:04 +0200 Subject: [PATCH 04/29] =?UTF-8?q?Remove=20FindGitRoot(string)=20=E2=80=94?= =?UTF-8?q?=20all=20callers=20use=20IDirectoryInfo=20overload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete Paths.FindGitRoot(string startPath): raw System.IO bootstrap overload is no longer needed; the docset anchor is always resolved first through IFileSystem, so the IDirectoryInfo overload covers every call site. - FileSystemFactory.InMemoryForPath / RealGitRootForPath / RealGitRootForPathWrite: switch from the deleted string overload to new FileSystem().DirectoryInfo.New() + FindGitRoot(IDirectoryInfo, maxParents). RealGitRootForPath also replaces the ad-hoc worktree resolution (Replace/Path.Join('..','..')) with TryReadGitDirPointer, which handles relative gitdir paths and commondir correctly. - Codex commands (CodexCommands x3, CodexIndexCommand, CodexUpdateRedirectsCommand): replace Paths.FindGitRoot(config.FullName) with the IDirectoryInfo overload via plain.DirectoryInfo.New(config.DirectoryName!). - GitCheckoutInformationFactory: extract the mock fallback condition into IsLegacyTestWithoutGitLayout() with a xmldoc summary that explains in clear human terms that this exists for test setups predating testable git resolution. Co-Authored-By: Claude Sonnet 4.6 --- .../FileSystemFactory.cs | 55 +++++++++++++------ .../GitCheckoutInformationFactory.cs | 48 +++++++++------- src/Elastic.Documentation.Tooling/Paths.cs | 39 ------------- .../Commands/Codex/CodexCommands.cs | 12 +++- .../Commands/Codex/CodexIndexCommand.cs | 4 +- .../Codex/CodexUpdateRedirectsCommand.cs | 4 +- 6 files changed, 80 insertions(+), 82 deletions(-) diff --git a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs index 5ba4746dfb..741c5eddb8 100644 --- a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs +++ b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs @@ -80,11 +80,14 @@ public static ScopedFileSystem InMemoryForPath(string? path) { if (path is null) return InMemory(); - var root = Paths.FindGitRoot(path); - if (root == Paths.WorkingDirectoryRoot.FullName) + var plain = new FileSystem(); + var startDir = plain.DirectoryInfo.New( + plain.Directory.Exists(path) ? path : plain.Path.GetDirectoryName(path) ?? path); + var gitRoot = Paths.FindGitRoot(startDir)?.FullName; + if (gitRoot is null || gitRoot == Paths.WorkingDirectoryRoot.FullName) return InMemory(); return new(new MockFileSystem(), new ScopedFileSystemOptions( - [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName, root]) + [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName, gitRoot]) { AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } @@ -209,28 +212,35 @@ public static ScopedFileSystem ScopeSourceDirectoryForWrite(IFileSystem inner, s /// public static ScopedFileSystem RealGitRootForPath(string? path) { - var root = path is null ? Paths.WorkingDirectoryRoot.FullName : Paths.FindGitRoot(path); + var plain = new FileSystem(); + string root; + if (path is null) + root = Paths.WorkingDirectoryRoot.FullName; + else + { + var startDir = plain.DirectoryInfo.New( + plain.Directory.Exists(path) ? path : plain.Path.GetDirectoryName(path) ?? path); + root = Paths.FindGitRoot(startDir)?.FullName ?? startDir.FullName; + } + var roots = new List { root, Paths.ApplicationData.FullName }; - // In a git worktree the local .git entry is a file pointing to the main repo's - // .git/worktrees/ directory. GitCheckoutInformation needs to read config and - // HEAD from the main repo's .git dir, which lives outside the worktree root. - // Add it as an explicit scope root so those reads are not rejected. - var worktreePointer = Path.Join(root, ".git"); - if (File.Exists(worktreePointer)) + // In a git worktree the local .git entry is a file pointing to the main repo's git dir. + // Resolve it via TryReadGitDirPointer (handles relative paths and commondir) and add the + // real .git directory to the scope so config/HEAD reads are not rejected. + var gitFilePath = plain.Path.Join(root, ".git"); + if (plain.File.Exists(gitFilePath) + && Paths.TryReadGitDirPointer(plain, plain.FileInfo.New(gitFilePath), out var resolvedGitDir) + && resolvedGitDir is not null) { - var gitdir = File.ReadAllText(worktreePointer).Replace("gitdir:", "").Trim(); - // gitdir = /main/repo/.git/worktrees/ — go up two levels to reach .git - var mainGitDir = Path.GetFullPath(Path.Join(gitdir, "..", "..")); - if (Directory.Exists(mainGitDir)) - roots.Add(mainGitDir); + roots.Add(resolvedGitDir.FullName); } // Fast path: no worktree detected and path was null — reuse the pre-built instance if (roots.Count == 2 && path is null) return RealRead; - return new ScopedFileSystem(new FileSystem(), new ScopedFileSystemOptions([.. roots]) + return new ScopedFileSystem(plain, new ScopedFileSystemOptions([.. roots]) { AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } @@ -248,10 +258,19 @@ public static ScopedFileSystem RealGitRootForPathWrite(string? path, string? out if (path is null && output is null) return RealWrite; - var gitRoot = path is not null ? Paths.FindGitRoot(path) : Paths.WorkingDirectoryRoot.FullName; + var plain = new FileSystem(); + string gitRoot; + if (path is not null) + { + var startDir = plain.DirectoryInfo.New( + plain.Directory.Exists(path) ? path : plain.Path.GetDirectoryName(path) ?? path); + gitRoot = Paths.FindGitRoot(startDir)?.FullName ?? startDir.FullName; + } + else + gitRoot = Paths.WorkingDirectoryRoot.FullName; + var roots = new List { gitRoot, Paths.ApplicationData.FullName }; - var plain = new FileSystem(); if (output is not null) { var absOutput = Path.IsPathRooted(output) ? output : Path.GetFullPath(output); diff --git a/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs b/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs index bc6975addd..382ba71407 100644 --- a/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs +++ b/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs @@ -31,28 +31,15 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem if (result == GitCheckoutInformation.Unavailable) { var fsType = fileSystem is ScopedFileSystem sf ? sf.InnerType : fileSystem.GetType(); - if (fsType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) + if (fsType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase) && IsLegacyTestWithoutGitLayout(fileSystem, source)) { - // Fall back to canned data for mocks in two cases that are not "real layout" attempts: - // - No .git entry at all (tests that don't model git) - // - .git is a directory without a config (tests that seed .git only so FindGitRoot - // can set DocumentationCheckoutDirectory but don't need real git info) - // Do NOT fall back when .git is a FILE (worktree pointer) — those tests are - // explicitly modeling a worktree layout and expect real or Unavailable results. - var gitPath = fileSystem.Path.Join(source.FullName, ".git"); - var noGitEntry = !fileSystem.Directory.Exists(gitPath) && !fileSystem.File.Exists(gitPath); - var gitDirWithoutConfig = fileSystem.Directory.Exists(gitPath) - && !fileSystem.File.Exists(fileSystem.Path.Join(gitPath, "config")); - if (noGitEntry || gitDirWithoutConfig) + return new GitCheckoutInformation { - return new GitCheckoutInformation - { - Branch = "test-e35fcb27-5f60-4e", - Remote = "elastic/docs-builder", - Ref = "e35fcb27-5f60-4e", - RepositoryName = "docs-builder" - }; - } + Branch = "test-e35fcb27-5f60-4e", + Remote = "elastic/docs-builder", + Ref = "e35fcb27-5f60-4e", + RepositoryName = "docs-builder" + }; } } @@ -162,6 +149,27 @@ private static GitCheckoutInformation TryCreate(IDirectoryInfo source, IFileSyst return info; } + /// + /// Returns for test setups that use a mock filesystem but do not seed a + /// real git layout — either no .git entry at all, or a bare .git directory without + /// a config file. Tests that only add .git/ to make FindGitRoot succeed + /// intentionally fall into the second case; they do not need real git metadata. + /// + /// These test setups pre-date testable git resolution and continue to receive the canned test + /// instance so they do not need to be updated. A .git file (worktree pointer) is + /// excluded: tests that seed a worktree pointer are explicitly modelling a worktree layout and + /// expect real resolution or . + /// + /// + private static bool IsLegacyTestWithoutGitLayout(IFileSystem fileSystem, IDirectoryInfo source) + { + var gitPath = fileSystem.Path.Join(source.FullName, ".git"); + var noGitEntry = !fileSystem.Directory.Exists(gitPath) && !fileSystem.File.Exists(gitPath); + var gitDirWithoutConfig = fileSystem.Directory.Exists(gitPath) + && !fileSystem.File.Exists(fileSystem.Path.Join(gitPath, "config")); + return noGitEntry || gitDirWithoutConfig; + } + private static string BranchTrackingRemote(string branch, IniFile config) { var sections = config.GetSections(); diff --git a/src/Elastic.Documentation.Tooling/Paths.cs b/src/Elastic.Documentation.Tooling/Paths.cs index fd374b69e0..cceea2f5e0 100644 --- a/src/Elastic.Documentation.Tooling/Paths.cs +++ b/src/Elastic.Documentation.Tooling/Paths.cs @@ -16,45 +16,6 @@ public static class Paths public static readonly DirectoryInfo ApplicationData = GetApplicationFolder(); - /// - /// Walks up from until a .git directory or file - /// (worktree pointer) is found and returns that ancestor. Returns - /// itself when no git root is found within the allowed depth. - /// - /// - /// Uses raw and is therefore only appropriate for the initial - /// scope-building bootstrap (before a - /// can be constructed). Callers that already have an should use - /// instead. - /// - public static string FindGitRoot(string startPath) - { - var resolved = Path.IsPathRooted(startPath) ? startPath : Path.GetFullPath(startPath); - var dir = Directory.Exists(resolved) - ? new DirectoryInfo(resolved) - : new DirectoryInfo(Path.GetDirectoryName(resolved) ?? resolved); - var startDir = dir.FullName; - var depth = 0; - while (dir != null) - { - var hasGit = dir.GetDirectories(".git").Length > 0 || dir.GetFiles(".git").Length > 0; - if (hasGit) - { -#if DEBUG - if (depth <= 1 || dir.GetFiles("*.slnx").Length > 0) - return dir.FullName; -#else - if (depth <= 1) - return dir.FullName; -#endif - return startDir; - } - depth++; - dir = dir.Parent; - } - return startDir; - } - /// /// Walks up from via until /// a .git directory or file (worktree pointer) is found. diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index cb11145b51..46317e9091 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -58,7 +58,9 @@ public async Task CloneAndBuild( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(new FileSystem(), [Paths.FindGitRoot(config.FullName)]); + var plain = new FileSystem(); + var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, + [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); var writeFs = FileSystemFactory.RealGitRootForPathWrite(null, output?.FullName); var configFile = readFs.FileInfo.New(config.FullName); @@ -116,7 +118,9 @@ public async Task Clone( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(new FileSystem(), [Paths.FindGitRoot(config.FullName)]); + var plain = new FileSystem(); + var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, + [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); var configFile = readFs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) @@ -150,7 +154,9 @@ public async Task Build( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(new FileSystem(), [Paths.FindGitRoot(config.FullName)]); + var plain = new FileSystem(); + var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, + [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); var writeFs = FileSystemFactory.RealGitRootForPathWrite(null, output?.FullName); var configFile = readFs.FileInfo.New(config.FullName); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 72f770940a..c2929d4b9c 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -44,7 +44,9 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(new FileSystem(), [Paths.FindGitRoot(config.FullName)]); + var plain = new FileSystem(); + var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, + [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); var configFile = readFs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index b5027a11c5..7afd9af5c9 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -35,7 +35,9 @@ public async Task UpdateRedirects( { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(new FileSystem(), [Paths.FindGitRoot(config.FullName)]); + var plain = new FileSystem(); + var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, + [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); var configFile = readFs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig)) return 1; From 1754b60af2008157731c7153379c6bd48a78018c Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 7 Aug 2026 08:33:21 +0200 Subject: [PATCH 05/29] Replace bare ScopedFileSystem with named use-case subclasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Every consumer previously received a non-descriptive ScopedFileSystem. The type communicated nothing about what it scoped or whether it was a read or write scope. BuildContext.ReadFileSystem and WriteFileSystem were both typed ScopedFileSystem, so nothing prevented callers from passing a read scope as a write scope — and several test sites did exactly that. Root cause: the filesystem factory pattern (FileSystemFactory) encoded scope policy as static helper methods and ambient statics (RealRead, RealWrite, AppData). These statics made the dependency invisible in signatures and impossible to stub per-invocation. The underlying bug (serve --path repo/docs showing 'unknown-docs') was caused by checkout resolution being scattered across three implementations that disagreed on where to walk. ## New type hierarchy (src/Elastic.Documentation.Tooling/FileSystems/ and src/Elastic.Documentation/FileSystems/) DocumentationFileSystem — read scope for a single documentation set. Constructed via DocumentationFileSystem.Resolve(invocation, options). Runs the six-step bootstrap (invocation → docset anchor → checkout → git dirs → git info → output) and exposes the result as .Paths. Owns .Write (a DocumentationWriteFileSystem) derived from the same resolved paths, so read and write can never disagree about the checkout. DocumentationWriteFileSystem — write scope for a documentation build. Identical roots to the read scope but .git is excluded from the allow-list. Constructed from the same ResolvedDocumentationPaths. CheckoutsFileSystem — read/write scope over a directory of clones. Used by the assembler, which operates on a tree of cloned repos rather than a single documentation set. No docset anchoring, no git resolution. ApplicationDataFileSystem — scope over the OS application-data directory. Used for the codex link-index clone. No per-invocation inputs. DocsetScanFileSystem — bootstrap-only scope rooted at the invocation path, nothing above it. Used by DocumentationPathsResolver step 1-2 to locate docset.yml without being able to escape the invocation root. GitResolveFileSystem — bootstrap-only scope rooted maxParents above the docset anchor (not the invocation). Used by steps 3-5 to walk up to .git and then read config/HEAD from resolved git directories (including worktree commondir targets that lie outside the anchor's ancestry). ## What changed DocumentationPathsResolver.Resolve implements the ordered six steps that were previously scattered across BuildContext's constructor, FileSystemFactory, and three separate git-resolution paths. Each step creates its own minimal bootstrap scope and discards it; the final DocumentationFileSystem carries only the resolved paths. BuildContext stores one DocumentationFileSystem and computes ReadFileSystem, WriteFileSystem, DocumentationSourceDirectory, DocumentationCheckoutDirectory, ConfigurationPath, OutputDirectory, and Git as projections of it. Nothing is copied independently, so nothing can drift out of agreement. The legacy ScopedFileSystem pair constructor on BuildContext is deleted. All seven src/ callers are migrated to DocumentationFileSystem.Resolve. DocumentationScopeOptions replaces seven separate constructor parameters: Output (--output), GitDir (--git-dir), ConfigurationFile (pre-discovered docset), Git (pre-computed override for Assembler/Codex), ExtraRoots (RUNNER_TEMP etc.), MaxParents, Inner (mock seam), InnerWrite (separate write mock for navigation tests). Worktree resolution fix: ResolveGitDirectories now uses the unscoped inner filesystem for commondir traversal, because the main .git directory lies outside the gitScope root by design. Explicit --git-dir carries its path into gitDirectories directly so the factory scope covers it. ## Tests DocumentationPathsResolverTests — 21 new tests covering: - Normal repo: --path /repo and --path /repo/docs resolve same checkout - Git worktree: checkout = worktree root, gitDirectories includes both the .git pointer path and the resolved commondir target - Explicit --git-dir: checkout = gitDir.Parent, git info from override - Mock FS without .git: graceful fallback, checkout = source - Output defaults to checkout/.artifacts (not invocation), same for both --path /repo and --path /repo/docs (regression guard) Nullean.ScopedFileSystem bumped to 0.4.1-canary.0.2 (local package) for the TryValidateSymlinkAccess early-exit fix needed when docRoot == directory. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi --- Directory.Packages.props | 2 +- nuget.config | 7 + .../Building/CodexBuildService.cs | 20 +- src/Elastic.Codex/CodexContext.cs | 9 +- .../BuildContext.cs | 107 ++-- .../DocumentationPathsResolver.cs | 210 ++++++++ .../FileSystems/ApplicationDataFileSystem.cs | 24 + .../FileSystems/CheckoutsFileSystem.cs | 92 ++++ .../FileSystems/DocsetScanFileSystem.cs | 22 + .../FileSystems/DocumentationFileSystem.cs | 111 +++++ .../FileSystems/GitResolveFileSystem.cs | 73 +++ .../DocumentationWriteFileSystem.cs | 96 ++++ .../IDocumentationContext.cs | 3 +- .../FormatService.cs | 6 +- .../MoveFileService.cs | 6 +- .../Tracking/LocalChangesService.cs | 6 +- .../AssembleContext.cs | 9 +- .../Building/AssemblerBuildService.cs | 3 +- .../Building/AssemblerSitemapService.cs | 6 +- .../RepositoryBuildMatchingService.cs | 6 +- .../RepositoryPublishValidationService.cs | 6 +- .../Indexing/AssemblerAiEnrichService.cs | 3 +- .../Indexing/AssemblerIndexService.cs | 3 +- .../Navigation/AssemblerDocumentationSet.cs | 22 +- .../Navigation/GlobalNavigationService.cs | 8 +- .../Sourcing/AssemblerCloneService.cs | 5 +- .../Synchronization/IDocsSyncContext.cs | 3 +- .../IsolatedBuildService.cs | 27 +- .../IsolatedIndexService.cs | 2 +- .../Assembler/AssemblerAiEnrichCommand.cs | 6 +- .../Commands/Assembler/AssemblerCommands.cs | 11 +- .../Assembler/AssemblerIndexCommand.cs | 6 +- .../Assembler/AssemblerSitemapCommand.cs | 3 +- .../Assembler/ContentSourceCommands.cs | 5 +- .../Commands/Assembler/DeployCommands.cs | 5 +- .../Commands/Assembler/NavigationCommands.cs | 5 +- .../Commands/Codex/CodexCommands.cs | 15 +- .../Commands/Codex/CodexIndexCommand.cs | 6 +- .../Commands/Codex/CodexSyncCommand.cs | 5 +- .../Commands/IsolatedBuildCommand.cs | 8 +- .../docs-builder/Commands/ServeCommand.cs | 2 +- .../docs-builder/Http/DocumentationWebHost.cs | 11 +- .../docs-builder/Http/InMemoryBuildState.cs | 8 +- .../AssemblerConfigurationTests.cs | 7 +- .../DocsSyncTests.cs | 7 +- .../IncrementalDeployRoundTripTests.cs | 7 +- .../NavigationBuildingTests.cs | 6 +- .../NavigationRootTests.cs | 6 +- .../SiteNavigationTests.cs | 10 +- .../ApiExplorerFixture.cs | 10 +- .../DashboardOpenApiNavigationTests.cs | 4 +- .../KibanaApiMarkdownNavigationTests.cs | 4 +- .../Elastic.ApiExplorer.Tests/ReaderTests.cs | 6 +- .../TagMetadataTests.cs | 4 +- .../ApiConfigurationTests.cs | 4 +- .../ConfigurationFileExcludeTests.cs | 4 +- .../ConfigurationFileReleaseNotesTests.cs | 4 +- ...ConfigurationFileStorybookRegistryTests.cs | 4 +- .../CrossLinkRegistryTests.cs | 4 +- .../DocumentationPathsResolverTests.cs | 455 ++++++++++++++++++ .../AssemblerHtmxMarkdownLinkTests.cs | 15 +- .../BrandingCopyTests.cs | 15 +- .../BuildContextConfigurationFileTests.cs | 40 +- ...textDocumentationCheckoutDirectoryTests.cs | 64 +-- .../Codex/CodexHtmxCrossLinkTests.cs | 5 +- .../Directives/ChangelogBasicTests.cs | 4 + .../Directives/DirectiveBaseTests.cs | 11 +- .../DocSet/NavigationTestsBase.cs | 18 +- .../DocSet/ReportIssueUrlTests.cs | 10 +- .../DocSet/RepositoryLinksTests.cs | 4 +- .../Inline/ImagePathResolutionTests.cs | 3 +- .../Inline/InlneBaseTests.cs | 3 +- .../MissingTocFileTests.cs | 3 +- .../Mover/MoverTests.cs | 6 +- .../OutputDirectoryTests.cs | 5 +- .../RootIndexValidationTests.cs | 7 +- tests/Elastic.Markdown.Tests/TestHelpers.cs | 21 + .../Codex/CodexNavigationTestBase.cs | 3 +- .../Codex/GroupNavigationTests.cs | 3 +- .../TestDocumentationSetContext.cs | 5 +- .../Framework/CrossLinkResolverAssertions.fs | 3 +- tests/authoring/Framework/Setup.fs | 13 +- 82 files changed, 1468 insertions(+), 332 deletions(-) create mode 100644 nuget.config create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs create mode 100644 src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs create mode 100644 tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 9fb62968e6..ccc18b07e9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,7 +45,7 @@ - + diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000000..36c8191882 --- /dev/null +++ b/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Elastic.Codex/Building/CodexBuildService.cs b/src/Elastic.Codex/Building/CodexBuildService.cs index 2b4faacb6f..988b275cb3 100644 --- a/src/Elastic.Codex/Building/CodexBuildService.cs +++ b/src/Elastic.Codex/Building/CodexBuildService.cs @@ -14,6 +14,7 @@ using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Isolated; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links; @@ -187,16 +188,13 @@ public async Task BuildAll( // The docset file itself is passed explicitly so build reuses the docset clone discovery already // selected (which may prefer a non-default path such as `docs-dev/`), rather than rediscovering // one from the repository root and always landing on `docs/`. - var buildContext = new BuildContext( - context.Collector, - fileSystem, - fileSystem, - configurationContext, - ExportOptions.Default, - checkout.RepositoryDirectory.FullName, - outputPath, - git, - configurationFile: checkout.DocsetFile) + var docFs = DocumentationFileSystem.Resolve(checkout.RepositoryDirectory, new DocumentationScopeOptions + { + Output = fileSystem.DirectoryInfo.New(outputPath), + Git = git, + ConfigurationFile = checkout.DocsetFile, + }); + var buildContext = new BuildContext(context.Collector, docFs, configurationContext) { UrlPathPrefix = pathPrefix, SiteRootPath = siteRootPath, @@ -417,7 +415,7 @@ internal sealed class CodexDocumentationContext(CodexContext codexContext) : ICo public ScopedFileSystem ReadFileSystem => codexContext.ReadFileSystem; /// - public ScopedFileSystem WriteFileSystem => codexContext.WriteFileSystem; + public DocumentationWriteFileSystem WriteFileSystem => codexContext.WriteFileSystem; /// public IDirectoryInfo OutputDirectory => codexContext.OutputDirectory; diff --git a/src/Elastic.Codex/CodexContext.cs b/src/Elastic.Codex/CodexContext.cs index 327a9743af..bf6f829533 100644 --- a/src/Elastic.Codex/CodexContext.cs +++ b/src/Elastic.Codex/CodexContext.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Deploying.Synchronization; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Codex; @@ -17,7 +18,7 @@ namespace Elastic.Codex; public class CodexContext : IDocsSyncContext { public ScopedFileSystem ReadFileSystem { get; } - public ScopedFileSystem WriteFileSystem { get; } + public DocumentationWriteFileSystem WriteFileSystem { get; } public IDiagnosticsCollector Collector { get; } public CodexConfiguration Configuration { get; } public IFileInfo ConfigurationPath { get; } @@ -38,7 +39,7 @@ public CodexContext( IFileInfo configurationPath, IDiagnosticsCollector collector, ScopedFileSystem readFileSystem, - ScopedFileSystem writeFileSystem, + DocumentationWriteFileSystem writeFileSystem, string? checkoutDirectory, string? outputDirectory) { @@ -52,8 +53,8 @@ public CodexContext( var defaultCheckoutDirectory = Path.Join(Paths.ApplicationData.FullName, "codex", "clone"); CheckoutDirectory = checkoutDirectory is null - ? FileSystemFactory.AppData.DirectoryInfo.New(defaultCheckoutDirectory) - : ReadFileSystem.DirectoryInfo.New(checkoutDirectory); + ? readFileSystem.DirectoryInfo.New(defaultCheckoutDirectory) + : readFileSystem.DirectoryInfo.New(checkoutDirectory); var defaultOutputDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "codex", "docs"); OutputDirectory = WriteFileSystem.DirectoryInfo.New(outputDirectory ?? defaultOutputDirectory); diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index a23420db2e..864bab185a 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration; @@ -22,46 +23,43 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public static string Version { get; } = Assembly.GetExecutingAssembly().GetCustomAttributes() .FirstOrDefault()?.InformationalVersion ?? "0.0.0"; - public ScopedFileSystem ReadFileSystem { get; } - public ScopedFileSystem WriteFileSystem { get; } - public IReadOnlySet AvailableExporters { get; } + /// The resolved documentation filesystem. All other path/scope properties are computed from this. + public DocumentationFileSystem FileSystem { get; } - public IDirectoryInfo? DocumentationCheckoutDirectory { get; } - public IDirectoryInfo DocumentationSourceDirectory { get; } - public IDirectoryInfo OutputDirectory { get; } + /// + /// Read scope. Returns the underlying as a + /// to satisfy . + /// Use directly when the richer type is needed. + /// + public ScopedFileSystem ReadFileSystem => FileSystem.Read; - public ConfigurationFile Configuration { get; private set; } + /// Write scope. Does not permit .git writes. + public DocumentationWriteFileSystem WriteFileSystem => FileSystem.Write; + + public IReadOnlySet AvailableExporters { get; init; } + public IDirectoryInfo? DocumentationCheckoutDirectory => FileSystem.Paths.CheckoutDirectory; + public IDirectoryInfo DocumentationSourceDirectory => FileSystem.Paths.SourceDirectory; + public IDirectoryInfo OutputDirectory => FileSystem.Paths.OutputDirectory; + public IFileInfo ConfigurationPath => FileSystem.Paths.ConfigurationPath; + public GitCheckoutInformation Git => FileSystem.Paths.Git; + + public ConfigurationFile Configuration { get; private set; } public DocumentationSetFile ConfigurationYaml { get; set; } public VersionsConfiguration VersionsConfiguration { get; } public ConfigurationFileProvider ConfigurationFileProvider { get; } public DocumentationEndpoints Endpoints { get; } - public ProductsConfiguration ProductsConfiguration { get; } public LegacyUrlMappingConfiguration LegacyUrlMappings { get; } public SearchConfiguration SearchConfiguration { get; } - - public IFileInfo ConfigurationPath { get; } - - public GitCheckoutInformation Git { get; } - public IEnvironmentVariables Environment { get; } - public IDiagnosticsCollector Collector { get; } - public bool Force { get; init; } - public BuildType BuildType { get; init; } = BuildType.Isolated; - - // This property is used to determine if the site should be indexed by search engines public bool AllowIndexing { get; init; } - public GoogleTagManagerConfiguration GoogleTagManager { get; init; } - public OptimizelyConfiguration Optimizely { get; init; } - - // This property is used for the canonical URL public Uri? CanonicalBaseUrl { get; init; } public string? UrlPathPrefix @@ -73,33 +71,20 @@ public string? UrlPathPrefix /// Site root path for HTMX (e.g. codex root). When set, overrides derivation from UrlPathPrefix. public string? SiteRootPath { get; init; } + /// + /// Primary constructor. Pass a resolved from + /// . + /// public BuildContext( IDiagnosticsCollector collector, - ScopedFileSystem fileSystem, + DocumentationFileSystem fileSystem, IConfigurationContext configurationContext, IEnvironmentVariables? environment = null - ) - : this(collector, fileSystem, fileSystem, configurationContext, ExportOptions.Default, null, null, environment: environment) - { - } - - public BuildContext( - IDiagnosticsCollector collector, - ScopedFileSystem readFileSystem, - ScopedFileSystem writeFileSystem, - IConfigurationContext configurationContext, - IReadOnlySet availableExporters, - string? source = null, - string? output = null, - GitCheckoutInformation? gitCheckoutInformation = null, - IEnvironmentVariables? environment = null, - IFileInfo? configurationFile = null ) { Collector = collector; - ReadFileSystem = readFileSystem; - WriteFileSystem = writeFileSystem; - AvailableExporters = availableExporters; + FileSystem = fileSystem; + AvailableExporters = ExportOptions.Default; Environment = environment ?? SystemEnvironmentVariables.Instance; SearchConfiguration = configurationContext.SearchConfiguration; VersionsConfiguration = configurationContext.VersionsConfiguration; @@ -108,46 +93,14 @@ public BuildContext( LegacyUrlMappings = configurationContext.LegacyUrlMappings; Endpoints = configurationContext.Endpoints; - var rootFolder = !string.IsNullOrWhiteSpace(source) - ? ReadFileSystem.DirectoryInfo.New(source) - : ReadFileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName)); - - // When the caller already discovered the docset (e.g. Codex clone discovery, which may prefer - // a non-default docset such as `docs-dev/` over `docs/`), use it directly instead of letting - // Paths.FindDocsFolderFromRoot rediscover a different one from `source`. - (DocumentationSourceDirectory, ConfigurationPath) = configurationFile is not null - ? (configurationFile.Directory!, configurationFile) - : Paths.FindDocsFolderFromRoot(ReadFileSystem, rootFolder); - - // Walk at most one parent above the docset anchor so that both - // --path repo/ (docset anchor = repo/docs, .git at repo — 1 parent) - // --path repo/docs/ (docset anchor = repo/docs, .git at repo — 1 parent) - // resolve to the same CheckoutDirectory, without ever escaping the scope root. - DocumentationCheckoutDirectory = Paths.FindGitRoot(DocumentationSourceDirectory, maxParents: 1); - - OutputDirectory = !string.IsNullOrWhiteSpace(output) - ? WriteFileSystem.DirectoryInfo.New(output) - : WriteFileSystem.DirectoryInfo.New(Path.Join(rootFolder.FullName, Path.Join(".artifacts", "docs", "html"))); - - if (ConfigurationPath.FullName != DocumentationSourceDirectory.FullName) - DocumentationSourceDirectory = ConfigurationPath.Directory!; - - Git = gitCheckoutInformation ?? GitCheckoutInformationFactory.Create(DocumentationCheckoutDirectory, ReadFileSystem); + GoogleTagManager = new GoogleTagManagerConfiguration { Enabled = false }; + Optimizely = new OptimizelyConfiguration { Enabled = false }; - // Load and resolve the docset file, or create an empty one if it doesn't exist ConfigurationYaml = ConfigurationPath.Exists - ? DocumentationSetFile.LoadAndResolve(collector, ConfigurationPath, readFileSystem) + ? DocumentationSetFile.LoadAndResolve(collector, ConfigurationPath, fileSystem.Read) : new DocumentationSetFile(); Configuration = new ConfigurationFile(ConfigurationYaml, this, VersionsConfiguration, ProductsConfiguration); - GoogleTagManager = new GoogleTagManagerConfiguration - { - Enabled = false - }; - Optimizely = new OptimizelyConfiguration - { - Enabled = false - }; } /// Re-reads docset.yml from disk and rebuilds the configuration. Used by the serve command on file changes. diff --git a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs index 0e397778c6..61d3e5e600 100644 --- a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs +++ b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs @@ -3,6 +3,9 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation; @@ -72,4 +75,211 @@ public sealed record ResolvedDocumentationPaths /// /.artifacts/docs/html. /// public required IDirectoryInfo OutputDirectory { get; init; } + + /// + /// Extra scope roots (e.g. RUNNER_TEMP, extension roots from + /// IDocsBuilderExtension.ExternalScopeRoots) to include in the read scope. + /// Disjointness-filtered: roots nested inside are dropped. + /// + public IReadOnlyList ExtraRoots { get; init; } = []; +} + +/// +/// Arguments that tune . +/// +public sealed record DocumentationScopeOptions +{ + /// Explicit output directory (--output). + public IDirectoryInfo? Output { get; init; } + + /// + /// Explicit --git-dir override — the .git directory; its .Parent is the checkout. + /// Worktrees are handled automatically through the commondir path; do not point this at a + /// worktree's internal gitdir (.git/worktrees/<name>). + /// + public IDirectoryInfo? GitDir { get; init; } + + /// Pre-discovered docset configuration file. When set, the docset scan is skipped. + public IFileInfo? ConfigurationFile { get; init; } + + /// + /// Git checkout information override (for tests). Replaces the GitCheckoutInformationFactory + /// call; goes through resolution rather than bypassing it. + /// + public GitCheckoutInformation? Git { get; init; } + + /// + /// Extra scope roots (e.g. RUNNER_TEMP, extension roots). Disjointness-filtered: roots + /// nested inside the resolved checkout are dropped instead of throwing. + /// + public IEnumerable? ExtraRoots { get; init; } + + /// + /// Maximum number of parent directories to walk above the docset anchor when searching for + /// .git (default: 1). + /// + public int MaxParents { get; init; } = 1; + + /// The underlying filesystem for reads. Defaults to the real filesystem. Pass a mock in tests. + public IFileSystem? Inner { get; init; } + + /// + /// Override the inner filesystem used for writes. When (the default), + /// is used for both read and write scopes. + /// Use this to wire a mock write scope against a real read scope (e.g. navigation tests that read + /// the real docs tree but write output to an in-memory filesystem). + /// + public IFileSystem? InnerWrite { get; init; } +} + +/// +/// Thrown when cannot find a docset or checkout. +/// +public sealed class DocumentationPathException(string message) : Exception(message); + +/// +/// Orchestrates the six ordered steps documented on . +/// Each step's bootstrap scope is created from what the previous step resolved, then discarded. +/// +public static class DocumentationPathsResolver +{ + /// + /// Resolve all paths for a documentation build/serve invocation. + /// + /// + /// No docset found, or no .git within MaxParents of the anchor and no + /// --git-dir override. + /// + public static ResolvedDocumentationPaths Resolve( + IDirectoryInfo invocation, + DocumentationScopeOptions options, + IFileSystem inner) + { + // 1-2. Anchor. Scoped to the invocation path only; skipped when the docset is already known. + var (source, configuration) = options.ConfigurationFile is { } known + ? (known.Directory!, known) + : ScanForDocset(invocation, inner); + + // 3. Checkout, derived from the anchor — never from the invocation. + var gitScope = new GitResolveFileSystem(source, options.MaxParents, inner: inner); + var checkout = ResolveCheckout(gitScope, source, options, inner); + + // 4. Real git directories (the .git pointer path + resolved target for worktrees). + // inner (unscoped) is used for worktree resolution: the resolved gitdir lives outside the + // anchor's ancestry by design, so a scoped FS would block the commondir traversal. + // When --git-dir is explicit the checkout is gitDir.Parent, so gitDir itself must be + // carried forward — ResolveGitDirectories can't find it via the gitScope (out-of-tree). + var gitDirectories = options.GitDir is { } explicitGitDir + ? [explicitGitDir.FullName] + : ResolveGitDirectories(gitScope, checkout, inner); + + // 5. Git information, through a scope widened by step 4. + // TryCreate reads config/HEAD from the resolved gitdir, which for a worktree lies outside + // the anchor's ancestry — so this is a second instance rather than the same one. + // This step uses a GitResolveFileSystem (for .git-aware scoping) because it reads FILES + // inside .git/ rather than listing directories at the scope root. + var git = options.Git ?? GitCheckoutInformationFactory.Create(checkout, + new GitResolveFileSystem(source, options.MaxParents, gitDirectories, inner)); + + // 6. Output. Default is relative to the checkout, not the invocation. + // --path repo/docs and --path repo/ must both write to repo/.artifacts, not repo/docs/.artifacts. + var output = options.Output ?? inner.DirectoryInfo.New( + inner.Path.Join(checkout.FullName, ".artifacts", "docs", "html")); + + // 7. Disjointness-filter the extra roots. + var extraRoots = FilterExtraRoots(options.ExtraRoots, checkout); + + return new ResolvedDocumentationPaths + { + InvocationPath = invocation, + SourceDirectory = source, + ConfigurationPath = configuration, + CheckoutDirectory = checkout, + GitDirectories = gitDirectories, + Git = git, + OutputDirectory = output, + ExtraRoots = extraRoots + }; + } + + private static (IDirectoryInfo, IFileInfo) ScanForDocset(IDirectoryInfo invocation, IFileSystem inner) + { + var scan = new DocsetScanFileSystem(invocation, inner); + if (!Paths.TryFindDocsFolderFromRoot(scan, scan.DirectoryInfo.New(invocation.FullName), out var dir, out var file)) + throw new DocumentationPathException( + $"No docset.yml or _docset.yml found in '{invocation.FullName}' or any subfolder."); + return (dir, file); + } + + private static IDirectoryInfo ResolveCheckout( + IFileSystem gitScope, + IDirectoryInfo source, + DocumentationScopeOptions options, + IFileSystem inner) + { + if (options.GitDir is { } explicitGitDir) + return explicitGitDir.Parent + ?? throw new DocumentationPathException( + $"--git-dir '{explicitGitDir.FullName}' has no parent directory."); + + var gitRoot = Paths.FindGitRoot(gitScope.DirectoryInfo.New(source.FullName), options.MaxParents); + if (gitRoot is not null) + return gitRoot; + + // Graceful fallback for mock filesystems without a .git layout (pure-in-memory test scenarios + // that do not need a real checkout boundary). Real filesystems always require an explicit checkout. + var innerType = inner is ScopedFileSystem sf ? sf.InnerType : inner.GetType(); + if (innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) + return source; + + throw new DocumentationPathException( + $"No .git found at '{source.FullName}' or within {options.MaxParents} parent directory(ies). " + + "Pass --git-dir to point at the repository's .git directory explicitly."); + } + + private static IReadOnlyList ResolveGitDirectories(IFileSystem gitScope, IDirectoryInfo checkout, IFileSystem inner) + { + var gitPath = gitScope.Path.Join(checkout.FullName, ".git"); + if (gitScope.Directory.Exists(gitPath)) + return [gitPath]; + + // Worktree: .git is a pointer file. Use inner (unscoped) for TryReadGitDirPointer so that + // the commondir traversal can reach the main .git directory, which lies outside the gitScope + // root by design (the worktree gitdir is inside the main repo's .git tree). + return Paths.TryReadGitDirPointer(inner, inner.FileInfo.New(gitPath), out var resolved) && resolved is not null + ? [gitPath, resolved.FullName] + : []; + } + + private static IReadOnlyList FilterExtraRoots( + IEnumerable? extraRoots, + IDirectoryInfo checkout) + { + if (extraRoots is null) + return []; + + var checkoutPath = checkout.FullName; + var result = new List(); + foreach (var root in extraRoots) + { + if (string.IsNullOrEmpty(root)) + continue; + // Drop descendants of checkout (already in scope) and ancestors (would subsume checkout). + if (!IsSubPath(root, checkoutPath) + && !IsSubPath(checkoutPath, root) + && !result.Contains(root, StringComparer.OrdinalIgnoreCase)) + { + result.Add(root); + } + } + return result; + } + + private static bool IsSubPath(string path, string parent) + { + var sep = Path.DirectorySeparatorChar; + var normalised = path.TrimEnd(sep) + sep; + var parentNormalised = parent.TrimEnd(sep) + sep; + return normalised.StartsWith(parentNormalised, StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs new file mode 100644 index 0000000000..dbf5b38a1a --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs @@ -0,0 +1,24 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// A scoped filesystem rooted at the per-user elastic/docs-builder application data folder. +/// Use for components that access caches or state and have no need for workspace files +/// (e.g. CrossLinkFetcher, CheckForUpdatesFilter, GitLinkIndexReader). +/// +public class ApplicationDataFileSystem(IFileSystem? inner = null) : ScopedFileSystem( + inner ?? new FileSystem(), + new ScopedFileSystemOptions([Paths.ApplicationData.FullName]) + { + // .git needed for codex-link-index clone directory inside ApplicationData + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }) +{ +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs new file mode 100644 index 0000000000..c244e8ec29 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs @@ -0,0 +1,92 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Scope over a directory containing cloned documentation checkouts. No docset anchoring, no git +/// information — the assembler reads configuration and per-clone output, not a documentation set. +/// +/// Use this for the assembler and changelog commands which work with a tree of clones. +/// Use when you have a single documentation set with a docset anchor. +/// +/// +public class CheckoutsFileSystem : ScopedFileSystem +{ + private static readonly FileSystem Physical = new(); + + private readonly IFileSystem _inner; + + public CheckoutsFileSystem( + IDirectoryInfo root, + IDirectoryInfo? output = null, + IEnumerable? extraRoots = null, + IFileSystem? inner = null) + : base(inner ?? Physical, BuildReadOptions(root, extraRoots)) + { + _inner = inner ?? Physical; + Write = new DocumentationWriteFileSystem(root, output, _inner); + } + + /// + /// This instance as a read scope. Always prefer .Read at call sites over passing the + /// instance directly — a slot that wants a read scope should say so, symmetrically with .Write. + /// + public CheckoutsFileSystem Read => this; + + /// Write scope for this checkout tree. + public DocumentationWriteFileSystem Write { get; } + + private static ScopedFileSystemOptions BuildReadOptions( + IDirectoryInfo root, + IEnumerable? extraRoots) + { + var rootPath = root.FullName; + var roots = new List { rootPath, Paths.ApplicationData.FullName }; + + if (extraRoots is not null) + { + foreach (var extra in extraRoots) + { + if (string.IsNullOrEmpty(extra)) + continue; + // Drop descendants of root (already covered) and ancestors (would subsume root, causing overlap). + if (!IsSubPath(extra, rootPath) + && !IsSubPath(rootPath, extra) + && !roots.Contains(extra, StringComparer.OrdinalIgnoreCase)) + { + roots.Add(extra); + } + } + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } + }; + } + + /// + /// Creates a scope over the current working directory root. Suitable for assembler and navigation + /// commands that operate on the local checkout tree without a specific docset anchor. + /// + /// The underlying filesystem. Defaults to a new when . + public static CheckoutsFileSystem FromWorkingDirectory(IFileSystem? inner = null) => + new( + (inner ?? Physical).DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + inner: inner); + + private static bool IsSubPath(string path, string parent) + { + var sep = System.IO.Path.DirectorySeparatorChar; + var normalised = path.TrimEnd(sep) + sep; + var parentNormalised = parent.TrimEnd(sep) + sep; + return normalised.StartsWith(parentNormalised, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs new file mode 100644 index 0000000000..dd3bf65839 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs @@ -0,0 +1,22 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Bootstrap-only filesystem rooted at the invocation path. Used only for the docset scan step of +/// ; discarded immediately after the anchor is found. +/// +/// Rooted at (and below) the invocation path because both scan strategies read at-or-below their root: +/// the known-location heuristic checks the path itself and its docs/ subdirectory, and the +/// recursive fallback enumerates downward. Nothing about the docset scan needs a parent directory. +/// +/// +internal sealed class DocsetScanFileSystem(IDirectoryInfo path, IFileSystem? inner = null) + : ScopedFileSystem(inner ?? new FileSystem(), new ScopedFileSystemOptions([path.FullName])) +{ +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs new file mode 100644 index 0000000000..3126dfb833 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs @@ -0,0 +1,111 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// The read scope for a single documentation set. Anchored on a resolved docset.yml and its +/// checkout root. Exposes a matching scope derived from the same paths — so read and +/// write cannot disagree about the checkout. +/// +/// Construction is via only. The constructor is private; it takes +/// already-resolved paths so that can run its bootstrap +/// scopes before the final scope is built. +/// +/// +public class DocumentationFileSystem : ScopedFileSystem +{ + private static readonly FileSystem Physical = new(); + + private DocumentationFileSystem(ResolvedDocumentationPaths paths, IFileSystem inner, IFileSystem? innerWrite = null) + : base(inner, BuildReadOptions(paths)) + { + Paths = paths; + Write = new DocumentationWriteFileSystem(paths.CheckoutDirectory, paths.OutputDirectory, innerWrite ?? inner); + } + + /// Everything the anchoring resolved. Read and write scopes are derived from exactly this. + public ResolvedDocumentationPaths Paths { get; } + + /// + /// This instance as a read scope. Always prefer .Read at call sites over passing the + /// instance directly — a slot that wants a read scope should say so, symmetrically with .Write. + /// + public DocumentationFileSystem Read => this; + + /// Write scope derived from the same resolved paths. Never wraps this. + public DocumentationWriteFileSystem Write { get; } + + /// + /// Anchor on the docset under , derive the checkout from it, and scope to + /// the result. For build/serve commands where the user supplied --path (or nothing). + /// + /// + /// The directory to start the docset scan from. When , the current working + /// directory is used. + /// + /// + /// Optional tuning: output directory, explicit --git-dir, pre-discovered configuration file, + /// extra scope roots, max parents for the git walk, and the mock seam. + /// + /// + /// No docset found under , or no .git within MaxParents of the + /// anchor and no --git-dir override. + /// + public static DocumentationFileSystem Resolve( + IDirectoryInfo? path = null, + DocumentationScopeOptions? options = null) + { + var opts = options ?? new DocumentationScopeOptions(); + var inner = opts.Inner ?? Physical; + var invocation = path ?? inner.DirectoryInfo.New(inner.Directory.GetCurrentDirectory()); + + // Docset first (scoped to the invocation path), then git from the anchor it produced. + // The resolver constructs its own bootstrap scopes in that order and discards them. + var paths = DocumentationPathsResolver.Resolve(invocation, opts, inner); + return new DocumentationFileSystem(paths, inner, opts.InnerWrite); + } + + private static ScopedFileSystemOptions BuildReadOptions(ResolvedDocumentationPaths paths) + { + var checkoutPath = paths.CheckoutDirectory.FullName; + var roots = new List { checkoutPath, Configuration.Paths.ApplicationData.FullName }; + + foreach (var gitDir in paths.GitDirectories) + { + if (!IsSubPath(gitDir, checkoutPath)) + roots.Add(gitDir); + } + + foreach (var extra in paths.ExtraRoots) + { + if (!string.IsNullOrEmpty(extra) + && !IsSubPath(extra, checkoutPath) + && !roots.Contains(extra, StringComparer.OrdinalIgnoreCase)) + { + roots.Add(extra); + } + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } + }; + } + + /// Returns true if is a subdirectory of + /// (or equals it), using a case-insensitive separator-normalised comparison. + private static bool IsSubPath(string path, string parent) + { + var sep = System.IO.Path.DirectorySeparatorChar; + var normalised = path.TrimEnd(sep) + sep; + var parentNormalised = parent.TrimEnd(sep) + sep; + return normalised.StartsWith(parentNormalised, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs new file mode 100644 index 0000000000..a94c093234 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs @@ -0,0 +1,73 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Bootstrap-only filesystem for git resolution steps in . +/// Discarded before BuildContext exists. +/// +/// Rooted at maxParents levels above the docset anchor — +/// git resolution measures from the anchor, not the invocation path, so that both +/// --path repo/ and --path repo/docs converge on the same checkout under a single bound. +/// +/// +/// The optional gitDirectories parameter widens the scope to include the resolved +/// .git directories (needed for the second pass that reads config, HEAD, and +/// refs/heads/* — for worktrees these lie outside the anchor's ancestry). +/// +/// +#pragma warning disable IDE0290 // Cannot use primary constructor — delegates to static helper +public class GitResolveFileSystem : ScopedFileSystem +{ + public GitResolveFileSystem( + IDirectoryInfo anchor, + int maxParents = 1, + IReadOnlyList? gitDirectories = null, + IFileSystem? inner = null) + : base(inner ?? new FileSystem(), BuildOptions(anchor, maxParents, gitDirectories)) + { + } +#pragma warning restore IDE0290 + + private static ScopedFileSystemOptions BuildOptions( + IDirectoryInfo anchor, + int maxParents, + IReadOnlyList? gitDirectories) + { + // Walk maxParents above the anchor to get the scope root. + var root = anchor; + for (var i = 0; i < maxParents; i++) + root = root.Parent ?? root; + + var rootPath = root.FullName; + var roots = new List { rootPath }; + + if (gitDirectories is { Count: > 0 }) + { + foreach (var gitDir in gitDirectories) + { + if (!IsSubPath(gitDir, rootPath)) + roots.Add(gitDir); + } + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }; + } + + private static bool IsSubPath(string path, string parent) + { + var sep = System.IO.Path.DirectorySeparatorChar; + var normalised = path.TrimEnd(sep) + sep; + var parentNormalised = parent.TrimEnd(sep) + sep; + return normalised.StartsWith(parentNormalised, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs new file mode 100644 index 0000000000..1042ec297e --- /dev/null +++ b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs @@ -0,0 +1,96 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Write scope for a documentation set or checkout tree. Sibling of DocumentationFileSystem +/// under ScopedFileSystem — it does not derive from +/// DocumentationFileSystem so that write slots typed to this class cannot silently accept +/// the read aggregate, and vice versa. +/// +/// The write scope intentionally omits .git from : +/// nothing in the build output pipeline should ever write into git repository metadata. +/// +/// +/// +/// Constructs the write scope for a documentation set. +/// +/// The repository checkout root (the directory containing .git). +/// +/// Optional explicit output directory. When it falls outside (e.g. +/// --output /tmp/build), it is added as a second scope root. When , +/// output is assumed to be under /.artifacts and is therefore already covered. +/// +/// +/// The underlying filesystem. Defaults to a new when . +/// Pass a mock in tests. +/// +public class DocumentationWriteFileSystem( + IDirectoryInfo checkout, + IDirectoryInfo? output = null, + IFileSystem? inner = null) + : ScopedFileSystem(inner ?? new FileSystem(), BuildOptions(checkout.FullName, output?.FullName, inner)) +{ + + /// + /// The per-user application data directory for elastic/docs-builder. + /// Same value as Paths.ApplicationData.FullName from the Tooling project, computed here to + /// avoid a circular project reference. + /// + private static string ApplicationDataPath + { + get + { + var localPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(localPath)) + localPath = System.IO.Path.GetTempPath(); + return System.IO.Path.Join(localPath, "elastic", "docs-builder"); + } + } + + private static ScopedFileSystemOptions BuildOptions( + string checkoutPath, + string? outputPath, + IFileSystem? inner) + { + var roots = new List { checkoutPath, ApplicationDataPath }; + + if (outputPath is not null) + { + var sep = System.IO.Path.DirectorySeparatorChar; + var outputNorm = outputPath.TrimEnd(sep) + sep; + var checkoutNorm = checkoutPath.TrimEnd(sep) + sep; + if (!outputNorm.StartsWith(checkoutNorm, StringComparison.OrdinalIgnoreCase)) + roots.Add(outputPath); + } + + // On non-Windows, MockFileSystem hardcodes a Unix-ified path ("/temp/", derived from "C:\temp") + // instead of calling System.IO.Path.GetTempPath(). AllowedSpecialFolder.Temp uses the real + // GetTempPath() (e.g. "/tmp/" on Linux), so the two diverge and scope validation fails for any + // path created via mockFs.Path.GetTempPath(). + // + // Fix tracked upstream: https://github.com/TestableIO/System.IO.Abstractions/pull/1454 + // Once that ships and we update the package reference we can drop this workaround. + var innerResolved = inner ?? new FileSystem(); + var innerType = innerResolved is ScopedFileSystem sf ? sf.InnerType : innerResolved.GetType(); + if (!OperatingSystem.IsWindows() && innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) + { + var innerTemp = innerResolved.Path.GetTempPath().TrimEnd( + System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); + if (!string.IsNullOrEmpty(innerTemp) && !roots.Contains(innerTemp, StringComparer.OrdinalIgnoreCase)) + roots.Add(innerTemp); + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" }, + AllowedSpecialFolders = AllowedSpecialFolder.Temp + }; + } +} diff --git a/src/Elastic.Documentation/IDocumentationContext.cs b/src/Elastic.Documentation/IDocumentationContext.cs index 9bc24b4f72..424c432c9e 100644 --- a/src/Elastic.Documentation/IDocumentationContext.cs +++ b/src/Elastic.Documentation/IDocumentationContext.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Documentation; @@ -12,7 +13,7 @@ public interface IDocumentationContext { IDiagnosticsCollector Collector { get; } ScopedFileSystem ReadFileSystem { get; } - ScopedFileSystem WriteFileSystem { get; } + DocumentationWriteFileSystem WriteFileSystem { get; } IDirectoryInfo OutputDirectory { get; } IFileInfo ConfigurationPath { get; } BuildType BuildType { get; } diff --git a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs index 591a8c8da9..cc5c28ab0c 100644 --- a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Refactor.Formatters; using Elastic.Documentation.Services; @@ -40,7 +41,10 @@ Cancel ctx ) { // Create BuildContext to load the documentation set - var context = new BuildContext(collector, fs, fs, configurationContext, ExportOptions.MetadataOnly, path, null); + var plain = new FileSystem(); + var invocation = path is not null ? plain.DirectoryInfo.New(path) : null; + var docFs = DocumentationFileSystem.Resolve(invocation); + var context = new BuildContext(collector, docFs, configurationContext) { AvailableExporters = ExportOptions.MetadataOnly }; var set = new DocumentationSet(context, logFactory, NoopCrossLinkResolver.Instance); var mode = checkOnly ? "Checking" : "Formatting"; diff --git a/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs b/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs index 91fbaedd56..644293c57a 100644 --- a/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Services; using Elastic.Markdown.IO; @@ -28,7 +29,10 @@ public async Task Move( Cancel ctx ) { - var context = new BuildContext(collector, fs, fs, configurationContext, ExportOptions.MetadataOnly, path, null); + var plain = new FileSystem(); + var invocation = path is not null ? plain.DirectoryInfo.New(path) : null; + var docFs = DocumentationFileSystem.Resolve(invocation); + var context = new BuildContext(collector, docFs, configurationContext) { AvailableExporters = ExportOptions.MetadataOnly }; var set = new DocumentationSet(context, logFactory, NoopCrossLinkResolver.Instance); diff --git a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs index c81f182790..051a4e9323 100644 --- a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.ScopedFileSystem; @@ -24,7 +25,10 @@ public Task ValidateRedirects(IDiagnosticsCollector collector, string? pat { var runningOnCi = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); - var buildContext = new BuildContext(collector, fs, fs, configurationContext, ExportOptions.MetadataOnly, path, null); + var plain = new FileSystem(); + var invocation = path is not null ? plain.DirectoryInfo.New(path) : null; + var docFs = DocumentationFileSystem.Resolve(invocation); + var buildContext = new BuildContext(collector, docFs, configurationContext) { AvailableExporters = ExportOptions.MetadataOnly }; var redirectFile = new RedirectFile(buildContext); if (!redirectFile.Source.Exists) { diff --git a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs index 2970240090..405764b40b 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Deploying.Synchronization; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler; @@ -18,7 +19,7 @@ namespace Elastic.Documentation.Assembler; public class AssembleContext : IDocumentationConfigurationContext, IDocsSyncContext { public ScopedFileSystem ReadFileSystem { get; } - public ScopedFileSystem WriteFileSystem { get; } + public DocumentationWriteFileSystem WriteFileSystem { get; } public IDiagnosticsCollector Collector { get; } @@ -67,7 +68,7 @@ public AssembleContext( string environment, IDiagnosticsCollector collector, ScopedFileSystem readFileSystem, - ScopedFileSystem writeFileSystem, + DocumentationWriteFileSystem writeFileSystem, string? checkoutDirectory, string? output ) @@ -95,8 +96,8 @@ public AssembleContext( var contentSource = Environment.ContentSource.ToStringFast(true); var defaultCheckoutDirectory = Path.Join(Paths.ApplicationData.FullName, "checkouts", contentSource); CheckoutDirectory = checkoutDirectory is null - ? FileSystemFactory.AppData.DirectoryInfo.New(defaultCheckoutDirectory) - : ReadFileSystem.DirectoryInfo.New(checkoutDirectory); + ? readFileSystem.DirectoryInfo.New(defaultCheckoutDirectory) + : readFileSystem.DirectoryInfo.New(checkoutDirectory); var defaultOutputDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); OutputDirectory = WriteFileSystem.DirectoryInfo.New(output ?? defaultOutputDirectory); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 5534fd7c98..5a164eec79 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -12,6 +12,7 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LegacyDocs; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Services; @@ -35,7 +36,7 @@ public async Task BuildAll( IDiagnosticsCollector collector, AssemblerBuildOptions options, ScopedFileSystem readFs, - ScopedFileSystem writeFs, + DocumentationWriteFileSystem writeFs, Cancel ctx ) { diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs index 3e532d189f..791c08fcc6 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs @@ -7,12 +7,12 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Search; using Elastic.Documentation.Search.Contract; using Elastic.Documentation.Services; using Elastic.Markdown.Exporters.Elasticsearch; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Building; @@ -27,7 +27,7 @@ ICoreService githubActionsService public async Task GenerateSitemapAsync( IDiagnosticsCollector collector, - ScopedFileSystem fileSystem, + CheckoutsFileSystem fileSystem, ElasticsearchIndexOptions es, string? environment = null, Cancel ctx = default @@ -40,7 +40,7 @@ public async Task GenerateSitemapAsync( var assembleContext = new AssembleContext( assemblyConfiguration, configurationContext, environment, collector, - fileSystem, fileSystem, null, null + fileSystem.Read, fileSystem.Write, null, null ); var cfg = configurationContext.Endpoints.Elasticsearch; diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs index 7de85a5a8d..f53d398442 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs @@ -7,11 +7,11 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.ContentSources; @@ -20,7 +20,7 @@ public class RepositoryBuildMatchingService( AssemblyConfiguration configuration, IConfigurationContext configurationContext, ICoreService githubActionsService, - ScopedFileSystem fileSystem + CheckoutsFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -71,7 +71,7 @@ public async Task ShouldBuild(IDiagnosticsCollector collector, string? rep var linkRegistry = await GetRegistryWithRetry(linkIndexProvider, ctx); var alreadyPublishing = linkRegistry.Repositories.ContainsKey(repositoryName); _logger.LogInformation("'{Repository}' (registry key: '{RepositoryName}') publishing to link registry: {PublishState} ", repo, repositoryName, alreadyPublishing); - var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); + var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem.Read, fileSystem.Write, null, null); var product = assembleContext.ProductsConfiguration.GetProductByRepositoryName(repo); var matches = assembleContext.Configuration.Match(logFactory, repo, refName, product, alreadyPublishing); if (matches is { Current: null, Next: null, Edge: null, Speculative: false }) diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs index 8f11b65b68..0f44cd2e41 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs @@ -7,10 +7,10 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.ContentSources; @@ -18,7 +18,7 @@ public class RepositoryPublishValidationService( ILoggerFactory logFactory, AssemblyConfiguration configuration, IConfigurationContext configurationContext, - ScopedFileSystem fileSystem + CheckoutsFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -27,7 +27,7 @@ ScopedFileSystem fileSystem public async Task ValidatePublishStatus(IDiagnosticsCollector collector, Cancel ctx) { // environment does not matter to check the configuration, defaulting to dev - var context = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); + var context = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem.Read, fileSystem.Write, null, null); ILinkIndexReader linkIndexReader = Aws3LinkIndexReader.CreateAnonymous(); var fetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexReader); var links = await fetcher.FetchLinkRegistry(ctx); diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs index a7c4395cef..9b9b9621e1 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs @@ -6,6 +6,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Indexing; using Elastic.Documentation.Services; using Elastic.Ingest.Elasticsearch.Enrichment; @@ -35,7 +36,7 @@ ICoreService githubActionsService public async Task AiEnrich( IDiagnosticsCollector collector, ScopedFileSystem readFs, - ScopedFileSystem writeFs, + DocumentationWriteFileSystem writeFs, ElasticsearchIndexOptions es, string? environment, bool bootstrapOnly, diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs index 02af5d5e7d..910cf2e0f0 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging; using Nullean.ScopedFileSystem; using static Elastic.Documentation.Exporter; @@ -28,7 +29,7 @@ IEnvironmentVariables environmentVariables public async Task Index( IDiagnosticsCollector collector, ScopedFileSystem readFs, - ScopedFileSystem writeFs, + DocumentationWriteFileSystem writeFs, ElasticsearchIndexOptions es, string? environment = null, Cancel ctx = default diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs index 4ddc90eeba..0b38498c4e 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs @@ -2,11 +2,13 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Assembler.Sourcing; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; @@ -51,17 +53,17 @@ IReadOnlySet availableExporters Branch = checkout.Repository.GetBranch(env.ContentSource) }; - var buildContext = new BuildContext( - context.Collector, - context.ReadFileSystem, - context.WriteFileSystem, - configurationContext, - availableExporters, - path, - output, - gitConfiguration - ) + var plain = new FileSystem(); + var invocationDir = plain.DirectoryInfo.New(path); + var outputDir = plain.DirectoryInfo.New(output); + var docFs = DocumentationFileSystem.Resolve(invocationDir, new DocumentationScopeOptions { + Output = outputDir, + Git = gitConfiguration, + }); + var buildContext = new BuildContext(context.Collector, docFs, configurationContext) + { + AvailableExporters = availableExporters, UrlPathPrefix = env.PathPrefix, Force = true, AllowIndexing = env.AllowIndexing, diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs index 18c621c8ad..347d577646 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs @@ -7,9 +7,9 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Navigation; @@ -17,12 +17,12 @@ public class GlobalNavigationService( ILoggerFactory logFactory, AssemblyConfiguration configuration, IConfigurationContext configurationContext, - ScopedFileSystem fileSystem + CheckoutsFileSystem fileSystem ) : IService { public async Task Validate(IDiagnosticsCollector collector, Cancel ctx) { - var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); + var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem.Read, fileSystem.Write, null, null); var namespaceChecker = new NavigationPrefixChecker(logFactory, assembleContext); var navigationFileInfo = assembleContext.ConfigurationFileProvider.NavigationFile; @@ -40,7 +40,7 @@ public async Task Validate(IDiagnosticsCollector collector, Cancel ctx) public async Task ValidateLocalLinkReference(IDiagnosticsCollector collector, string? file, Cancel ctx) { file ??= ".artifacts/docs/html/links.json"; - var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); + var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem.Read, fileSystem.Write, null, null); var root = fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); var repository = GitCheckoutInformationFactory.Create(root, fileSystem, logFactory.CreateLogger(nameof(GitCheckoutInformation))).RepositoryName diff --git a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs index f3522d25c1..97cbd783ab 100644 --- a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs +++ b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -25,8 +26,8 @@ public async Task CloneAll(IDiagnosticsCollector collector, AssemblerClone var githubEnvironmentInput = githubActionsService.GetInput("environment"); var environment = options.Environment ?? (!string.IsNullOrEmpty(githubEnvironmentInput) ? githubEnvironmentInput : "dev"); - var fs = FileSystemFactory.RealRead; - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs, fs, null, null); + var cfs = CheckoutsFileSystem.FromWorkingDirectory(); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, cfs.Read, cfs.Write, null, null); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); _ = await cloner.CloneAll(options.FetchLatest ?? false, options.AssumeCloned ?? false, ctx); diff --git a/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs b/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs index c93e39c568..2d57ca5614 100644 --- a/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs +++ b/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Deploying.Synchronization; @@ -15,7 +16,7 @@ namespace Elastic.Documentation.Deploying.Synchronization; public interface IDocsSyncContext { ScopedFileSystem ReadFileSystem { get; } - ScopedFileSystem WriteFileSystem { get; } + DocumentationWriteFileSystem WriteFileSystem { get; } IDirectoryInfo OutputDirectory { get; } IDiagnosticsCollector Collector { get; } diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index f2422aa2b4..3b754d11a4 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Inference; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; @@ -23,7 +24,6 @@ using Elastic.Markdown.Page; using Microsoft.Extensions.Logging; using Nullean.ScopedFileSystem; -using static System.StringComparison; namespace Elastic.Documentation.Isolated; @@ -46,9 +46,8 @@ public bool IsStrict(bool? strict) public async Task Build( IDiagnosticsCollector collector, - ScopedFileSystem fileSystem, IsolatedBuildOptions options, - ScopedFileSystem? writeFileSystem = null, + IFileSystem? writeFileSystem = null, Cancel ctx = default ) { @@ -84,10 +83,19 @@ public async Task Build( force = true; } + var plain = new FileSystem(); + var invocation = path is not null ? plain.DirectoryInfo.New(path) : null; + var outputDir = options.Output is not null ? plain.DirectoryInfo.New(options.Output.FullName) : null; try { - context = new BuildContext(collector, fileSystem, writeFileSystem ?? fileSystem, configurationContext, exporters, path, output) + var docFs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { + Output = outputDir, + InnerWrite = writeFileSystem, + }); + context = new BuildContext(collector, docFs, configurationContext) + { + AvailableExporters = exporters, UrlPathPrefix = pathPrefix, Force = force ?? false, AllowIndexing = allowIndexing ?? false, @@ -97,17 +105,16 @@ public async Task Build( // On CI, we are running on a merge commit which may have changes against an older // docs folder (this can happen on out-of-date PR's). // At some point in the future we can remove this try catch - catch (Exception e) when (runningOnCi && e.Message.StartsWith("Can not locate docset.yml file in", OrdinalIgnoreCase)) + catch (DocumentationPathException e) when (runningOnCi) { // Derive the default output from `path` so it stays within the write FS scope. // Using Paths.WorkingDirectoryRoot would be wrong when --path points to a different repo. var rootFolder = !string.IsNullOrWhiteSpace(path) ? path : Paths.WorkingDirectoryRoot.FullName; - var writeFs = writeFileSystem ?? fileSystem; - var outputDirectory = !string.IsNullOrWhiteSpace(output) - ? writeFs.DirectoryInfo.New(output) - : writeFs.DirectoryInfo.New(Path.Join(rootFolder, ".artifacts/docs/html")); + var fallbackFs = writeFileSystem ?? plain; + var outputDirectory = outputDir + ?? fallbackFs.DirectoryInfo.New(Path.Join(rootFolder, ".artifacts/docs/html")); // we temporarily do not error when pointed to a non-documentation folder. - _ = writeFs.Directory.CreateDirectory(outputDirectory.FullName); + _ = fallbackFs.Directory.CreateDirectory(outputDirectory.FullName); _logger.LogInformation("Skipping build as we are running on a merge commit and the docs folder is out of date and has no docset.yml. {Message}", e.Message); diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs index 8f615612d2..2771b8f9ba 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs @@ -33,7 +33,7 @@ public async Task Index( var cfg = _configurationContext.Endpoints.Elasticsearch; await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); - return await Build(collector, fileSystem, new IsolatedBuildOptions + return await Build(collector, new IsolatedBuildOptions { Path = path != null ? new DirectoryInfo(path) : null, MetadataOnly = true, diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs index b93e9d3d67..081b90e33d 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -43,8 +44,9 @@ public async Task AiEnrich( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.RealRead; - var writeFs = FileSystemFactory.RealWrite; + var aifs = CheckoutsFileSystem.FromWorkingDirectory(); + var readFs = aifs.Read; + var writeFs = aifs.Write; var service = new AssemblerAiEnrichService(logFactory, configuration, configurationContext, githubActionsService); serviceInvoker.AddCommand(service, async (s, col, ctx) => await s.AiEnrich(col, readFs, writeFs, es, environment, bootstrapOnly, ctx) diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index f89094b7f8..d1c53b6ee2 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -62,8 +63,9 @@ public async Task Assemble( static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) ); - var readFs = FileSystemFactory.RealRead; - var writeFs = FileSystemFactory.RealWrite; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); + var readFs = fs.Read; + var writeFs = fs.Write; var buildService = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); serviceInvoker.AddCommand(buildService, (buildOptions, readFs, writeFs), buildOptions.Strict ?? false, static async (s, col, state, ctx) => await s.BuildAll(col, state.buildOptions, state.readFs, state.writeFs, ctx) @@ -146,8 +148,9 @@ public async Task Build( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.RealRead; - var writeFs = FileSystemFactory.RealWrite; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); + var readFs = fs.Read; + var writeFs = fs.Write; var service = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); serviceInvoker.AddCommand(service, (options, readFs, writeFs), options.Strict ?? false, static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state.readFs, state.writeFs, ctx) diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs index 26412a93cc..4f7b9aadb2 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -42,8 +43,9 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.RealRead; - var writeFs = FileSystemFactory.RealWrite; + var ifs = CheckoutsFileSystem.FromWorkingDirectory(); + var readFs = ifs.Read; + var writeFs = ifs.Write; var service = new AssemblerIndexService(logFactory, configuration, configurationContext, githubActionsService, environmentVariables); serviceInvoker.AddCommand(service, async (s, col, ctx) => await s.Index(col, readFs, writeFs, es, environment, ctx) diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs index d0e2bbc825..0f0407d18f 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -41,7 +42,7 @@ public async Task Sitemap( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealWrite; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new AssemblerSitemapService(logFactory, configuration, configurationContext, githubActionsService); serviceInvoker.AddCommand(service, async (s, col, ctx) => await s.GenerateSitemapAsync(col, fs, es, environment, ctx) diff --git a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs index 9cfe6be0e3..c19dbdd7d2 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -30,7 +31,7 @@ public async Task Validate(CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealRead; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new RepositoryPublishValidationService(logFactory, configuration, configurationContext, fs); serviceInvoker.AddCommand(service, static async (s, collector, ctx) => await s.ValidatePublishStatus(collector, ctx)); @@ -46,7 +47,7 @@ public async Task Match([Argument] string? repository = null, [Argument] st { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealRead; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new RepositoryBuildMatchingService(logFactory, configuration, configurationContext, githubActionsService, fs); serviceInvoker.AddCommand(service, (repository, branchOrTag), static async (s, collector, state, ctx) => diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index ed1b452197..b019c7fe9c 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Deploying; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -43,7 +44,7 @@ public async Task Plan(string environment, string s3BucketName, [ExpandUser { await using var serviceInvoker = new ServiceInvoker(collector); - var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, FileSystemFactory.RealRead, FileSystemFactory.RealWrite, null, null); + var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, CheckoutsFileSystem.FromWorkingDirectory().Read, CheckoutsFileSystem.FromWorkingDirectory().Write, null, null); var service = new IncrementalDeployService(logFactory, githubActionsService); serviceInvoker.AddCommand(service, (context, s3BucketName, @out, deleteThreshold), static async (s, collector, state, ctx) => await s.Plan(collector, state.context, state.s3BucketName, state.@out?.FullName ?? "", state.deleteThreshold, [], ctx) @@ -64,7 +65,7 @@ public async Task Apply(string environment, string s3BucketName, [Existing, { await using var serviceInvoker = new ServiceInvoker(collector); - var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, FileSystemFactory.RealRead, FileSystemFactory.RealWrite, null, null); + var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, CheckoutsFileSystem.FromWorkingDirectory().Read, CheckoutsFileSystem.FromWorkingDirectory().Write, null, null); var service = new IncrementalDeployService(logFactory, githubActionsService); serviceInvoker.AddCommand(service, (context, s3BucketName, planFile), static async (s, collector, state, ctx) => await s.Apply(collector, state.context, state.s3BucketName, state.planFile.FullName, ctx) diff --git a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs index 7f1f03820e..9a3e877e26 100644 --- a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -28,7 +29,7 @@ IConfigurationContext configurationContext public async Task Validate(CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new GlobalNavigationService(logFactory, configuration, configurationContext, FileSystemFactory.RealRead); + var service = new GlobalNavigationService(logFactory, configuration, configurationContext, CheckoutsFileSystem.FromWorkingDirectory()); serviceInvoker.AddCommand(service, static async (s, collector, ctx) => await s.Validate(collector, ctx)); return await serviceInvoker.InvokeAsync(ct); } @@ -39,7 +40,7 @@ public async Task Validate(CancellationToken ct = default) public async Task ValidateLinkReference([Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? file = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new GlobalNavigationService(logFactory, configuration, configurationContext, FileSystemFactory.RealRead); + var service = new GlobalNavigationService(logFactory, configuration, configurationContext, CheckoutsFileSystem.FromWorkingDirectory()); serviceInvoker.AddCommand(service, file, static async (s, collector, file, ctx) => await s.ValidateLocalLinkReference(collector, file?.FullName, ctx)); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 46317e9091..bf6be5080d 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -12,6 +12,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Isolated; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Services; @@ -61,7 +62,10 @@ public async Task CloneAndBuild( var plain = new FileSystem(); var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); - var writeFs = FileSystemFactory.RealGitRootForPathWrite(null, output?.FullName); + var writeFs = new DocumentationWriteFileSystem( + plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + output is null ? null : plain.DirectoryInfo.New(output.FullName), + plain); var configFile = readFs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) @@ -126,7 +130,9 @@ public async Task Clone( if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, FileSystemFactory.RealWrite, null, null); + var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, + new DocumentationWriteFileSystem(plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, plain), + null, null); using var linkIndexReader = new GitLinkIndexReader(environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); @@ -157,7 +163,10 @@ public async Task Build( var plain = new FileSystem(); var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); - var writeFs = FileSystemFactory.RealGitRootForPathWrite(null, output?.FullName); + var writeFs = new DocumentationWriteFileSystem( + plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + output is null ? null : plain.DirectoryInfo.New(output.FullName), + plain); var configFile = readFs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out _)) diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index c2929d4b9c..b8dec8a4ef 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -5,13 +5,13 @@ using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using Actions.Core.Services; - using Elastic.Codex; using Elastic.Codex.Indexing; using Elastic.Codex.Sourcing; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Isolated; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -51,7 +51,9 @@ public async Task Index( if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, FileSystemFactory.RealWrite, null, null); + var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, + new DocumentationWriteFileSystem(plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, plain), + null, null); var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs index cbef92df6d..ed50015ff6 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Deploying; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -88,7 +89,9 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex var fs = FileSystemFactory.RealRead; var configFile = fs.FileInfo.New(config.FullName); var codexConfig = CodexConfiguration.Load(configFile); - return (new CodexContext(codexConfig, configFile, collector, fs, FileSystemFactory.RealWrite, null, null), + var writeFs = new DocumentationWriteFileSystem( + fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, null); + return (new CodexContext(codexConfig, configFile, collector, fs, writeFs, null, null), new IncrementalDeployService(logFactory, githubActionsService)); } } diff --git a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs index cc9d478428..32c61c2227 100644 --- a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs +++ b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using Actions.Core.Services; using Elastic.Documentation; using Elastic.Documentation.Configuration; @@ -42,12 +43,11 @@ public async Task Build( await using var serviceInvoker = new ServiceInvoker(collector); var service = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); - var readFs = inMemory ? FileSystemFactory.InMemory() : FileSystemFactory.RealGitRootForPath(options.Path?.FullName); - var writeFs = inMemory ? null : FileSystemFactory.RealGitRootForPathWrite(options.Path?.FullName, options.Output?.FullName); + IFileSystem? writeFs = inMemory ? new MockFileSystem() : null; var strictCommand = service.IsStrict(options.Strict); - serviceInvoker.AddCommand(service, (options, readFs, writeFs), strictCommand, - static async (s, col, state, ctx) => await s.Build(col, state.readFs, state.options, state.writeFs, ctx) + serviceInvoker.AddCommand(service, (options, writeFs), strictCommand, + static async (s, col, state, ctx) => await s.Build(col, state.options, state.writeFs, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/ServeCommand.cs b/src/tooling/docs-builder/Commands/ServeCommand.cs index 0d2ff3afa3..9c4b759957 100644 --- a/src/tooling/docs-builder/Commands/ServeCommand.cs +++ b/src/tooling/docs-builder/Commands/ServeCommand.cs @@ -25,7 +25,7 @@ internal sealed class ServeCommand(ILoggerFactory logFactory, IConfigurationCont [CommandName("serve")] public async Task Serve(GlobalCliOptions _, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, int port = 3000, bool watch = false, CancellationToken ct = default) { - var host = new DocumentationWebHost(logFactory, path?.FullName, port, FileSystemFactory.RealGitRootForPath(path?.FullName), FileSystemFactory.InMemoryForPath(path?.FullName), configurationContext, watch); + var host = new DocumentationWebHost(logFactory, path?.FullName, port, configurationContext, watch); await host.RunAsync(ct); _logger.LogInformation("Find your documentation at http://localhost:{Port}/{Path}", port, host.GeneratorState.Generator.DocumentationSet.FirstInterestingUrl.TrimStart('/') diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 395437a6f8..bb94d15060 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using System.Net; using Nullean.ScopedFileSystem; using System.Runtime.InteropServices; @@ -15,6 +16,7 @@ using Elastic.Documentation.Api; #endif using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ServiceDefaults; using Elastic.Documentation.Site.FileProviders; using Elastic.Markdown.IO; @@ -44,13 +46,10 @@ public class DocumentationWebHost public DocumentationWebHost(ILoggerFactory logFactory, string? path, int port, - ScopedFileSystem readFs, - ScopedFileSystem writeFs, IConfigurationContext configurationContext, bool isWatchBuild ) { - _writeFileSystem = writeFs; var builder = WebApplication.CreateSlimBuilder(); _ = builder.AddDocumentationServiceDefaults(); @@ -71,7 +70,11 @@ bool isWatchBuild var hostUrl = $"http://localhost:{port}"; _hostedService = collector; - Context = new BuildContext(collector, readFs, writeFs, configurationContext, ExportOptions.Default, path, null) + var plain = new FileSystem(); + var invocation = path is not null ? plain.DirectoryInfo.New(path) : null; + var docFs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { InnerWrite = new MockFileSystem() }); + _writeFileSystem = docFs.Write; + Context = new BuildContext(collector, docFs, configurationContext) { CanonicalBaseUrl = new Uri(hostUrl), }; diff --git a/src/tooling/docs-builder/Http/InMemoryBuildState.cs b/src/tooling/docs-builder/Http/InMemoryBuildState.cs index d882cd195e..c1d2463259 100644 --- a/src/tooling/docs-builder/Http/InMemoryBuildState.cs +++ b/src/tooling/docs-builder/Http/InMemoryBuildState.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using System.Text.Json.Serialization; using System.Threading.Channels; using Actions.Core; @@ -14,7 +15,6 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Isolated; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Documentation.Builder.Http; @@ -62,7 +62,7 @@ public class InMemoryBuildState(ILoggerFactory loggerFactory, IConfigurationCont // Initialized lazily on first ExecuteBuildAsync so we can scope it to the source path. // Exposed so the serve host can read pagefind index files written by the background build. private string? _writeFsPath; - public ScopedFileSystem? WriteFileSystem { get; private set; } + public IFileSystem? WriteFileSystem { get; private set; } // Broadcast: maintain list of connected client channels private readonly Lock _clientsLock = new(); @@ -164,10 +164,9 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) // Create a diagnostics collector that streams to our channel var streamingCollector = new StreamingDiagnosticsCollector(_loggerFactory, this); - var readFs = FileSystemFactory.RealGitRootForPath(sourcePath); if (WriteFileSystem is null || _writeFsPath != sourcePath) { - WriteFileSystem = FileSystemFactory.InMemoryForPath(sourcePath); + WriteFileSystem = new MockFileSystem(); _writeFsPath = sourcePath; } var service = new IsolatedBuildService(_loggerFactory, _configurationContext, new NullCoreService(), SystemEnvironmentVariables.Instance); @@ -176,7 +175,6 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) _ = await service.Build( streamingCollector, - readFs, new IsolatedBuildOptions { Path = new DirectoryInfo(sourcePath), diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 6008c930fa..295054c6c9 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; using Nullean.ScopedFileSystem; @@ -30,7 +31,8 @@ public PublicOnlyAssemblerConfigurationTests() var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem, configurationFileProvider: configurationFileProvider); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, scopedFs, CheckoutDirectory.FullName, null); + var writeFs = new DocumentationWriteFileSystem(FileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, writeFs, CheckoutDirectory.FullName, null); } [Fact] @@ -67,7 +69,8 @@ public AssemblerConfigurationTests(DocumentationFixture fixture, ITestOutputHelp var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, scopedFs, CheckoutDirectory.FullName, null); + var writeFs = new DocumentationWriteFileSystem(FileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, writeFs, CheckoutDirectory.FullName, null); } [Fact] diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs index 5f90a5d00d..56e9e5527f 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Deploying.Synchronization; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Integrations.S3; using Elastic.Documentation.ServiceDefaults.Telemetry; using FakeItEasy; @@ -47,7 +48,7 @@ public async Task TestPlan() var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var scopedWriteFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + var scopedWriteFs = new DocumentationWriteFileSystem(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fileSystem); var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs, scopedWriteFs, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); A.CallTo(() => mockS3Client.ListObjectsV2Async(A._, A._)) .Returns(new ListObjectsV2Response @@ -190,7 +191,7 @@ bool valid var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var scopedFs2 = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var scopedWriteFs2 = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + var scopedWriteFs2 = new DocumentationWriteFileSystem(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fileSystem); var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs2, scopedWriteFs2, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); var s3Objects = new List(); @@ -242,7 +243,7 @@ public async Task TestApply() var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var checkoutDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); var scopedFs3 = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var scopedWriteFs3 = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + var scopedWriteFs3 = new DocumentationWriteFileSystem(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fileSystem); var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs3, scopedWriteFs3, null, checkoutDirectory); var plan = new SyncPlan { diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs index 5facecbe6a..7978b38ecf 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs @@ -16,6 +16,7 @@ using Elastic.Documentation.Deploying; using Elastic.Documentation.Deploying.Synchronization; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Integrations.S3; using FakeItEasy; using Microsoft.Extensions.Logging; @@ -48,7 +49,7 @@ public async Task AssemblerRoundTrip() var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new DiagnosticsCollector([]); var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var scopedWriteFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fs); + var scopedWriteFs = new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs); var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs, scopedWriteFs, null, outputDir); await RunRoundTrip(fs, s3, xfer, gh, svc, context, outputDir); @@ -61,7 +62,7 @@ public async Task CodexRoundTrip() var (fs, s3, xfer, gh, svc) = Arrange(outputDir); var collector = new DiagnosticsCollector([]); var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var scopedWriteFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fs); + var scopedWriteFs = new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs); // CodexContext only stores configurationPath — it never reads from it — // so we can point to any path without adding it to the mock FS. var codexConfig = new CodexConfiguration { Environment = "dev" }; @@ -228,7 +229,7 @@ public async Task ExcludedRemoteObjectsAreNotDeleted() var svc = new IncrementalDeployService(new LoggerFactory(), gh, s3, xfer, etagCalculator); var collector = new DiagnosticsCollector([]); var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var scopedWriteFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fs); + var scopedWriteFs = new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs); var codexConfig = new CodexConfiguration { Environment = "dev" }; var configFile = fs.FileInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); var context = new CodexContext(codexConfig, configFile, collector, scopedFs, scopedWriteFs, null, outputDir); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs index 5f34d33929..08b7953b8a 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated.Leaf; @@ -46,7 +47,10 @@ public async Task AssertRealNavigation() var assemblyConfiguration = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); var fs = new FileSystem(); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fs), FileSystemFactory.ScopeCurrentWorkingDirectory(new MockFileSystem()), null, null); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, + FileSystemFactory.ScopeCurrentWorkingDirectory(fs), + new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs), + null, null); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs index 00e9624f6a..9e2add1c3a 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; @@ -46,7 +47,10 @@ public async Task AssertRealNavigation() var assemblyConfiguration = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); var fs = new FileSystem(); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fs), FileSystemFactory.ScopeCurrentWorkingDirectory(new MockFileSystem()), null, null); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, + FileSystemFactory.ScopeCurrentWorkingDirectory(fs), + new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs), + null, null); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs index f3cb9a098c..8b4de28972 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Assembler; using Elastic.Markdown.IO; @@ -45,7 +46,8 @@ public SiteNavigationTests(DocumentationFixture fixture, ITestOutputHelper outpu var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, scopedFs, CheckoutDirectory.FullName, null); + var writeFs = new DocumentationWriteFileSystem(FileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, writeFs, CheckoutDirectory.FullName, null); } private Checkout CreateCheckout(IFileSystem fs, Repository repository) @@ -99,7 +101,8 @@ public async Task ReadAllPathPrefixes() var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var scopedFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFileSystem, scopedFileSystem, null, null); + var writeFileSystem = new DocumentationWriteFileSystem(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFileSystem, writeFileSystem, null, null); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; var siteNavigationFile = SiteNavigationFile.Deserialize(await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken)); @@ -192,7 +195,8 @@ public async Task UriResolving() var configurationContext = TestHelpers.CreateConfigurationContext(fs); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, scopedFs, scopedFs, null, null); + var assembleWriteFs = new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs); + var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, scopedFs, assembleWriteFs, null, null); var repos = assembleContext.Configuration.AvailableRepositories .Where(kv => !kv.Value.Skip) .Select(kv => kv.Value) diff --git a/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs b/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs index e643e1f2ea..bcd8ca2f73 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs @@ -8,10 +8,10 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.OpenApi; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -40,10 +40,10 @@ public sealed class ApiExplorerFixture : IAsyncLifetime public async ValueTask InitializeAsync() { - var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - // RealGitRootForPath(null) rather than RealRead: it adds the main repo's .git dir as a scope - // root when the checkout is a git worktree, which BuildContext needs to read git information. - Context = new BuildContext(new DiagnosticsCollector([]), FileSystemFactory.RealGitRootForPath(null), configurationContext); + var realFs = new FileSystem(); + var configurationContext = TestHelpers.CreateConfigurationContext(realFs); + var invocation = realFs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + Context = new BuildContext(new DiagnosticsCollector([]), DocumentationFileSystem.Resolve(invocation), configurationContext); var fs = new FileSystem(); var path = fs.Path.Combine(AppContext.BaseDirectory, "TestData", "api-explorer-fixture.json"); diff --git a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs index 5092713dac..35118e8912 100644 --- a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs @@ -10,8 +10,8 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -25,7 +25,7 @@ public class DashboardOpenApiNavigationTests public async Task CreateNavigation_SingleTagOpenApiSpec_HasSidebarItems() { var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(new DiagnosticsCollector([]), FileSystemFactory.RealGitRootForPath(null), configurationContext); + var context = new BuildContext(new DiagnosticsCollector([]), DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), configurationContext); var fs = new FileSystem(); var path = fs.Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "dashboard-openapi.json"); var fi = fs.FileInfo.New(path); diff --git a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs index 12fdef7014..0ba16aca46 100644 --- a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs @@ -13,11 +13,11 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -51,7 +51,7 @@ private static (LandingNavigationItem navigation, SimpleMarkdownNavigationItem i var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var context = new BuildContext(collector, FileSystemFactory.RealGitRootForPath(null), configurationContext); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), configurationContext); var doc = OpenApiReader.Create(specFile).GetAwaiter().GetResult(); doc.Should().NotBeNull("OpenAPI document should load successfully"); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); diff --git a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs index 368ccffd23..f9ce4cb01e 100644 --- a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs @@ -10,8 +10,8 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -23,7 +23,7 @@ public async Task Reads() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, FileSystemFactory.RealGitRootForPath(null), configurationContext); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), configurationContext); context.Configuration.OpenApiSpecifications.Should().NotBeNull().And.NotBeEmpty(); @@ -38,7 +38,7 @@ public async Task Navigation() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, FileSystemFactory.RealGitRootForPath(null), configurationContext); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), configurationContext); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); context.Configuration.OpenApiSpecifications.Should().NotBeNull().And.NotBeEmpty(); diff --git a/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs b/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs index 9eeea1b638..ec77882a01 100644 --- a/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs @@ -12,10 +12,10 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.OpenApi; using Microsoft.OpenApi.Reader; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -285,7 +285,7 @@ public async Task ApiTag_StableNavigationIds_UsesCanonicalTagName() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, FileSystemFactory.RealGitRootForPath(null), configurationContext); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), configurationContext); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index c2193fc514..e04e236638 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; using Nullean.ScopedFileSystem; using YamlDotNet.Serialization; @@ -453,7 +454,8 @@ private sealed class MockDocumentationSetContext( { public IDiagnosticsCollector Collector => collector; public ScopedFileSystem ReadFileSystem => WriteFileSystem; - public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs index f3582b6e3e..e17893f2a3 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration.Tests; @@ -88,7 +89,8 @@ private sealed class MockDocumentationSetContext( { public IDiagnosticsCollector Collector => collector; public ScopedFileSystem ReadFileSystem => WriteFileSystem; - public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs index bb289aa76a..0251cce6d2 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration.Tests; @@ -157,7 +158,8 @@ private sealed class MockDocumentationSetContext( { public IDiagnosticsCollector Collector => collector; public ScopedFileSystem ReadFileSystem => WriteFileSystem; - public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs index d86c7f1f90..088d097574 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration.Tests; @@ -106,7 +107,8 @@ private sealed class MockDocumentationSetContext( { public IDiagnosticsCollector Collector => collector; public ScopedFileSystem ReadFileSystem => WriteFileSystem; - public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs index b259fdff5a..3b82f69886 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration.Tests; @@ -134,7 +135,8 @@ private sealed class MockDocumentationSetContext( { public IDiagnosticsCollector Collector => collector; public ScopedFileSystem ReadFileSystem => WriteFileSystem; - public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs new file mode 100644 index 0000000000..950425041d --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs @@ -0,0 +1,455 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; + +namespace Elastic.Documentation.Configuration.Tests; + +/// +/// Tests for and +/// , covering the six-step bootstrap: +/// invocation → docset anchor → checkout → git directories → git info → output. +/// +public class DocumentationPathsResolverTests +{ + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// + /// Builds a minimal regular-repo filesystem: + /// /repo/.git/{HEAD,config,refs/...} + + /// /repo/docs/docset.yml + /// + private static MockFileSystem RegularRepo( + string repoRoot = "/repo", + string docsRelative = "docs", + string branch = "main", + string sha = "abc1234", + string remote = "elastic/test-repo") + { + var fs = new MockFileSystem(); + var docsPath = $"{repoRoot}/{docsRelative}"; + fs.AddDirectory($"{repoRoot}/.git"); + fs.AddFile($"{repoRoot}/.git/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); + fs.AddFile($"{repoRoot}/.git/refs/heads/{branch}", new MockFileData($"{sha}\n")); + fs.AddFile($"{repoRoot}/.git/config", new MockFileData($""" + [remote "origin"] + url = https://github.com/{remote}.git + [branch "{branch}"] + remote = origin + merge = refs/heads/{branch} + """)); + fs.AddFile($"{docsPath}/docset.yml", new MockFileData("toc: []\n")); + return fs; + } + + /// + /// Builds a worktree filesystem: + /// /worktree/.git (file) → /main/.git/worktrees/wt → commondir → /main/.git + /// + private static MockFileSystem WorktreeWithCommondir( + string worktreeRoot = "/worktree", + string mainRoot = "/main", + string branch = "topic", + string sha = "fedcba987654", + string remote = "elastic/worktree-repo", + string docsRelative = "docs") + { + var fs = new MockFileSystem(); + var worktreeGitDir = $"{mainRoot}/.git/worktrees/wt"; + var mainGitDir = $"{mainRoot}/.git"; + var docsPath = $"{worktreeRoot}/{docsRelative}"; + + // Worktree: .git is a pointer file + fs.AddFile($"{worktreeRoot}/.git", new MockFileData($"gitdir: {worktreeGitDir}\n")); + + // Worktree-specific gitdir with commondir pointing back to the main .git + fs.AddDirectory(worktreeGitDir); + fs.AddFile($"{worktreeGitDir}/commondir", new MockFileData("../..\n")); // → /main/.git + + // Main .git has the shared objects, config, and HEAD + fs.AddDirectory(mainGitDir); + fs.AddFile($"{mainGitDir}/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); + fs.AddFile($"{mainGitDir}/refs/heads/{branch}", new MockFileData($"{sha}\n")); + fs.AddFile($"{mainGitDir}/config", new MockFileData($""" + [remote "origin"] + url = https://github.com/{remote}.git + [branch "{branch}"] + remote = origin + merge = refs/heads/{branch} + """)); + + fs.AddFile($"{docsPath}/docset.yml", new MockFileData("toc: []\n")); + return fs; + } + + // ----------------------------------------------------------------------- + // Docset scan + checkout convergence + // ----------------------------------------------------------------------- + + [Fact] + public void InvocationAtRepoRoot_ResolvesDocsetInDocsSubfolder() + { + var fs = RegularRepo(); + var invocation = fs.DirectoryInfo.New("/repo"); + + var paths = DocumentationPathsResolver.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }, fs); + + paths.SourceDirectory.FullName.Should().Be("/repo/docs"); + paths.CheckoutDirectory.FullName.Should().Be("/repo"); + } + + [Fact] + public void InvocationAtDocsSubfolder_ResolvesDocsetAndCheckout() + { + var fs = RegularRepo(); + var invocation = fs.DirectoryInfo.New("/repo/docs"); + + var paths = DocumentationPathsResolver.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }, fs); + + paths.SourceDirectory.FullName.Should().Be("/repo/docs"); + paths.CheckoutDirectory.FullName.Should().Be("/repo"); + } + + [Fact] + public void PathRepoRoot_And_PathDocsSubfolder_ResolveIdenticalCheckoutAndSource() + { + var fs = RegularRepo(); + + var fromRoot = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/repo"), + new DocumentationScopeOptions { Inner = fs }, fs); + + var fromDocs = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/repo/docs"), + new DocumentationScopeOptions { Inner = fs }, fs); + + fromRoot.CheckoutDirectory.FullName.Should().Be(fromDocs.CheckoutDirectory.FullName, + "--path /repo and --path /repo/docs must converge on the same checkout"); + fromRoot.SourceDirectory.FullName.Should().Be(fromDocs.SourceDirectory.FullName, + "--path /repo and --path /repo/docs must converge on the same source"); + } + + [Fact] + public void InvocationPath_StoredVerbatim_IndependentOfCheckout() + { + var fs = RegularRepo(); + var invocationDir = fs.DirectoryInfo.New("/repo/docs"); + + var paths = DocumentationPathsResolver.Resolve(invocationDir, new DocumentationScopeOptions { Inner = fs }, fs); + + paths.InvocationPath.FullName.Should().Be("/repo/docs"); + paths.CheckoutDirectory.FullName.Should().Be("/repo"); + } + + // ----------------------------------------------------------------------- + // Git directory resolution + // ----------------------------------------------------------------------- + + [Fact] + public void RegularRepo_GitDirectories_ContainsOneEntry() + { + var fs = RegularRepo(); + + var paths = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/repo"), + new DocumentationScopeOptions { Inner = fs }, fs); + + paths.GitDirectories.Should().ContainSingle() + .Which.Should().Be("/repo/.git"); + } + + [Fact] + public void RegularRepo_GitInfo_IsResolved() + { + var fs = RegularRepo(branch: "my-branch", sha: "deadbeef1234", remote: "elastic/my-repo"); + + var paths = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/repo"), + new DocumentationScopeOptions { Inner = fs }, fs); + + paths.Git.IsAvailable.Should().BeTrue(); + paths.Git.Branch.Should().Be("my-branch"); + paths.Git.Ref.Should().Be("deadbeef1234"); + paths.Git.RepositoryName.Should().Be("my-repo"); + } + + // ----------------------------------------------------------------------- + // Git worktree + // ----------------------------------------------------------------------- + + [Fact] + public void Worktree_CheckoutIsWorktreeRoot_NotMainRepo() + { + var fs = WorktreeWithCommondir(); + + var paths = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/worktree"), + new DocumentationScopeOptions { Inner = fs }, fs); + + paths.CheckoutDirectory.FullName.Should().Be("/worktree"); + } + + [Fact] + public void Worktree_GitDirectories_ContainsPointerAndMainGit() + { + var fs = WorktreeWithCommondir(); + + var paths = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/worktree"), + new DocumentationScopeOptions { Inner = fs }, fs); + + paths.GitDirectories.Should().HaveCount(2); + paths.GitDirectories.Should().Contain("/worktree/.git", + "pointer file path must be in scope so the .git file is readable"); + paths.GitDirectories.Should().Contain("/main/.git", + "resolved commondir target must be included so config/HEAD are readable"); + } + + [Fact] + public void Worktree_GitInfo_ResolvedFromMainDotGit() + { + var fs = WorktreeWithCommondir(branch: "topic", sha: "fedcba987654", remote: "elastic/worktree-repo"); + + var paths = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/worktree"), + new DocumentationScopeOptions { Inner = fs }, fs); + + paths.Git.IsAvailable.Should().BeTrue(); + paths.Git.Branch.Should().Be("topic"); + paths.Git.Ref.Should().Be("fedcba987654"); + paths.Git.RepositoryName.Should().Be("worktree-repo"); + } + + [Fact] + public void Worktree_InvocationAtDocsSubfolder_ResolvesIdenticallyToWorktreeRoot() + { + var fs = WorktreeWithCommondir(); + + var fromWorktreeRoot = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/worktree"), + new DocumentationScopeOptions { Inner = fs }, fs); + + var fromDocs = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/worktree/docs"), + new DocumentationScopeOptions { Inner = fs }, fs); + + fromWorktreeRoot.CheckoutDirectory.FullName.Should().Be(fromDocs.CheckoutDirectory.FullName, + "worktree: --path /worktree and --path /worktree/docs must resolve to the same checkout"); + } + + // ----------------------------------------------------------------------- + // Explicit --git-dir override + // ----------------------------------------------------------------------- + + [Fact] + public void ExplicitGitDir_CheckoutIsGitDirParent() + { + // Layout: docset at /project/docs/, .git at /repo/.git (out-of-tree) + var fs = new MockFileSystem(); + fs.AddDirectory("/repo/.git"); + fs.AddFile("/repo/.git/HEAD", new MockFileData("ref: refs/heads/main\n")); + fs.AddFile("/repo/.git/refs/heads/main", new MockFileData("cafe1234\n")); + fs.AddFile("/repo/.git/config", new MockFileData(""" + [remote "origin"] + url = https://github.com/elastic/override-test.git + [branch "main"] + remote = origin + merge = refs/heads/main + """)); + fs.AddFile("/project/docs/docset.yml", new MockFileData("toc: []\n")); + + var opts = new DocumentationScopeOptions + { + Inner = fs, + GitDir = fs.DirectoryInfo.New("/repo/.git") + }; + + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project/docs"), opts, fs); + + paths.CheckoutDirectory.FullName.Should().Be("/repo", + "--git-dir /repo/.git → checkout = /repo/.git.Parent = /repo"); + } + + [Fact] + public void ExplicitGitDir_GitInfo_ResolvedFromOverriddenGitDir() + { + var fs = new MockFileSystem(); + fs.AddDirectory("/repo/.git"); + fs.AddFile("/repo/.git/HEAD", new MockFileData("ref: refs/heads/main\n")); + fs.AddFile("/repo/.git/refs/heads/main", new MockFileData("aabbcc99\n")); + fs.AddFile("/repo/.git/config", new MockFileData(""" + [remote "origin"] + url = https://github.com/elastic/override-repo.git + [branch "main"] + remote = origin + merge = refs/heads/main + """)); + fs.AddFile("/project/docs/docset.yml", new MockFileData("toc: []\n")); + + var opts = new DocumentationScopeOptions + { + Inner = fs, + GitDir = fs.DirectoryInfo.New("/repo/.git") + }; + + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project/docs"), opts, fs); + + paths.Git.IsAvailable.Should().BeTrue(); + paths.Git.Branch.Should().Be("main"); + paths.Git.Ref.Should().Be("aabbcc99"); + paths.Git.RepositoryName.Should().Be("override-repo"); + } + + // ----------------------------------------------------------------------- + // Mock filesystem — no git layout (graceful fallback) + // ----------------------------------------------------------------------- + + [Fact] + public void MockFsWithoutGit_DoesNotThrow_CheckoutFallsBackToSource() + { + var fs = new MockFileSystem(); + fs.AddFile("/repo/docs/docset.yml", new MockFileData("toc: []\n")); + + var opts = new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }; + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo/docs"), opts, fs); + + paths.CheckoutDirectory.FullName.Should().Be("/repo/docs", + "mock FS fallback: no .git → checkout = source directory"); + paths.GitDirectories.Should().BeEmpty(); + } + + [Fact] + public void MockFsWithoutGit_GitOverride_IsPreservedVerbatim() + { + var fs = new MockFileSystem(); + fs.AddFile("/repo/docs/docset.yml", new MockFileData("toc: []\n")); + + var opts = new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }; + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo/docs"), opts, fs); + + paths.Git.IsAvailable.Should().BeFalse(); + paths.Git.Should().Be(GitCheckoutInformation.Unavailable); + } + + // ----------------------------------------------------------------------- + // Output directory default + // ----------------------------------------------------------------------- + + [Fact] + public void Output_DefaultsToCheckoutArtifacts() + { + var fs = RegularRepo(); + var opts = new DocumentationScopeOptions { Inner = fs }; + + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); + + // Default output is checkout/.artifacts/docs/html — NOT the invocation path. + paths.OutputDirectory.FullName.Should().StartWith("/repo/.artifacts"); + } + + [Fact] + public void Output_InvocationAtDocsSubfolder_StillAnchorsToCheckout() + { + // Regression: before this fix, --path /repo/docs/ wrote to /repo/docs/.artifacts + // instead of /repo/.artifacts. Checkout is /repo, so artifacts belong there. + var fs = RegularRepo(); + var opts = new DocumentationScopeOptions { Inner = fs }; + + var fromRoot = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); + var fromDocs = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo/docs"), opts, fs); + + fromRoot.OutputDirectory.FullName.Should().Be(fromDocs.OutputDirectory.FullName, + "--path /repo and --path /repo/docs must produce the same default output directory"); + } + + [Fact] + public void Output_ExplicitOverride_IsRespected() + { + var fs = RegularRepo(); + var opts = new DocumentationScopeOptions + { + Inner = fs, + Output = fs.DirectoryInfo.New("/custom/output") + }; + + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); + + paths.OutputDirectory.FullName.Should().Be("/custom/output"); + } + + // ----------------------------------------------------------------------- + // ConfigurationFile option (pre-discovered docset) + // ----------------------------------------------------------------------- + + [Fact] + public void PreDiscoveredConfigFile_SkipsDocsetScan() + { + // Docset is at /project/docs/docset.yml, but invocation is the project root + var fs = RegularRepo(repoRoot: "/project"); + var docsetFile = fs.FileInfo.New("/project/docs/docset.yml"); + + var opts = new DocumentationScopeOptions { Inner = fs, ConfigurationFile = docsetFile }; + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project"), opts, fs); + + paths.SourceDirectory.FullName.Should().Be("/project/docs"); + paths.ConfigurationPath.FullName.Should().Be("/project/docs/docset.yml"); + } + + // ----------------------------------------------------------------------- + // No docset found → throws + // ----------------------------------------------------------------------- + + [Fact] + public void NoDocset_Throws_DocumentationPathException() + { + var fs = new MockFileSystem(); + fs.AddDirectory("/empty"); + + var act = () => DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/empty"), + new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }, + fs); + + act.Should().Throw() + .WithMessage("*docset.yml*"); + } + + // ----------------------------------------------------------------------- + // DocumentationFileSystem.Resolve — integration (same scenarios via the public API) + // ----------------------------------------------------------------------- + + [Fact] + public void DocumentationFileSystem_Resolve_RegularRepo_ExposesResolvedPaths() + { + var fs = RegularRepo(branch: "feature", sha: "001122", remote: "elastic/docs-builder"); + var invocation = fs.DirectoryInfo.New("/repo"); + + var docFs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }); + + docFs.Paths.CheckoutDirectory.FullName.Should().Be("/repo"); + docFs.Paths.SourceDirectory.FullName.Should().Be("/repo/docs"); + docFs.Paths.Git.Branch.Should().Be("feature"); + docFs.Paths.Git.RepositoryName.Should().Be("docs-builder"); + } + + [Fact] + public void DocumentationFileSystem_Resolve_Worktree_ExposesMainGitInfo() + { + var fs = WorktreeWithCommondir(branch: "topic", sha: "99aabb", remote: "elastic/worktree-test"); + var invocation = fs.DirectoryInfo.New("/worktree"); + + var docFs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }); + + docFs.Paths.CheckoutDirectory.FullName.Should().Be("/worktree"); + docFs.Paths.Git.Branch.Should().Be("topic"); + docFs.Paths.Git.Ref.Should().Be("99aabb"); + docFs.Paths.Git.RepositoryName.Should().Be("worktree-test"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs index bd9cb950e3..9ba1bfd67a 100644 --- a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs @@ -9,7 +9,6 @@ using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; using Elastic.Markdown.Tests.Inline; -using Nullean.ScopedFileSystem; using Xunit; namespace Elastic.Markdown.Tests.Assembler; @@ -24,7 +23,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -59,7 +58,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -87,7 +86,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs", BuildType = BuildType.Assembler @@ -117,7 +116,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -146,7 +145,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -185,7 +184,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -213,7 +212,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler diff --git a/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs b/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs index 22ff9a961d..8c640eff1d 100644 --- a/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs +++ b/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs @@ -18,7 +18,7 @@ public async Task CopyBrandingResources_SeparateFileSystems_DoesNotThrow() { var logger = new TestLoggerFactory(output); - var readFs = new MockFileSystem(new Dictionary + var fs = new MockFileSystem(new Dictionary { { "docs/docset.yml", //language=yaml @@ -36,16 +36,9 @@ public async Task CopyBrandingResources_SeparateFileSystems_DoesNotThrow() CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - var writeFs = new MockFileSystem(new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); - await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); - var configurationContext = TestHelpers.CreateConfigurationContext(readFs); - var readScoped = FileSystemFactory.ScopeCurrentWorkingDirectory(readFs); - var writeScoped = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(writeFs); - var context = new BuildContext(collector, readScoped, writeScoped, configurationContext, ExportOptions.Default); + var configurationContext = TestHelpers.CreateConfigurationContext(fs); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fs), configurationContext); var linkResolver = new TestCrossLinkResolver(); var set = new DocumentationSet(context, logger, linkResolver); @@ -55,6 +48,6 @@ public async Task CopyBrandingResources_SeparateFileSystems_DoesNotThrow() await collector.StopAsync(TestContext.Current.CancellationToken); var outputStaticDir = Path.Join(set.OutputDirectory.FullName, "_static"); - writeFs.File.Exists(Path.Join(outputStaticDir, "logo.svg")).Should().BeTrue(); + fs.File.Exists(Path.Join(outputStaticDir, "logo.svg")).Should().BeTrue(); } } diff --git a/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs b/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs index 61c6ed9e09..f654ad9f31 100644 --- a/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs +++ b/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs @@ -7,7 +7,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using Xunit; namespace Elastic.Markdown.Tests; @@ -33,21 +33,19 @@ public void ExplicitConfigurationFile_OverridesDefaultDiscovery() fs.AddFile(publicDocsetPath, new MockFileData("toc: []\n")); fs.AddFile(internalDocsetPath, new MockFileData("registry: internal\ntoc: []\n")); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); + var docFs = DocumentationFileSystem.Resolve( + fs.DirectoryInfo.New(repoPath), + new DocumentationScopeOptions + { + Inner = fs, + ConfigurationFile = fs.FileInfo.New(internalDocsetPath), + Output = fs.DirectoryInfo.New(Path.Combine(root, "codex-configuration-file-test-out")) + }); - var context = new BuildContext( - collector, - readFs, - writeFs, - configurationContext, - ExportOptions.Default, - source: repoPath, - output: Path.Combine(root, "codex-configuration-file-test-out"), - configurationFile: readFs.FileInfo.New(internalDocsetPath)); + var context = new BuildContext(collector, docFs, configurationContext); context.ConfigurationPath.FullName.Should().Be(internalDocsetPath); context.DocumentationSourceDirectory.FullName.Should().Be(Path.Combine(repoPath, "docs-dev")); @@ -67,20 +65,18 @@ public void NoExplicitConfigurationFile_FallsBackToDefaultDiscovery() fs.AddDirectory(Path.Combine(repoPath, ".git")); fs.AddFile(publicDocsetPath, new MockFileData("toc: []\n")); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); + var docFs = DocumentationFileSystem.Resolve( + fs.DirectoryInfo.New(repoPath), + new DocumentationScopeOptions + { + Inner = fs, + Output = fs.DirectoryInfo.New(Path.Combine(root, "codex-configuration-file-fallback-test-out")) + }); - var context = new BuildContext( - collector, - readFs, - writeFs, - configurationContext, - ExportOptions.Default, - source: repoPath, - output: Path.Combine(root, "codex-configuration-file-fallback-test-out")); + var context = new BuildContext(collector, docFs, configurationContext); context.ConfigurationPath.FullName.Should().Be(publicDocsetPath); } diff --git a/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs b/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs index c76d0649d8..646a16c82c 100644 --- a/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs +++ b/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs @@ -7,7 +7,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using Xunit; namespace Elastic.Markdown.Tests; @@ -36,19 +36,17 @@ public void SourceAsRepositoryRoot_SetsDocumentationCheckoutDirectory() fs.AddDirectory(Path.Combine(repoPath, ".git")); fs.AddFile(Path.Combine(repoPath, "docs", "docset.yml"), new MockFileData("toc: []\n")); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var context = new BuildContext( - collector, - readFs, - writeFs, - configurationContext, - ExportOptions.Default, - source: repoPath, - output: Path.Combine(root, "codex-checkout-dir-test-out")); + var docFs = DocumentationFileSystem.Resolve( + fs.DirectoryInfo.New(repoPath), + new DocumentationScopeOptions + { + Inner = fs, + Output = fs.DirectoryInfo.New(Path.Combine(root, "codex-checkout-dir-test-out")) + }); + var context = new BuildContext(collector, docFs, configurationContext); Assert.NotNull(context.DocumentationCheckoutDirectory); context.DocumentationCheckoutDirectory.FullName.Should().Be(repoPath); @@ -64,19 +62,17 @@ public void SourceAsDocsSubtree_ResolvesCheckoutFromParent() fs.AddDirectory(Path.Combine(repoPath, ".git")); fs.AddFile(Path.Combine(docsPath, "docset.yml"), new MockFileData("toc: []\n")); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var context = new BuildContext( - collector, - readFs, - writeFs, - configurationContext, - ExportOptions.Default, - source: docsPath, - output: Path.Combine(root, "codex-docs-only-test-out")); + var docFs = DocumentationFileSystem.Resolve( + fs.DirectoryInfo.New(docsPath), + new DocumentationScopeOptions + { + Inner = fs, + Output = fs.DirectoryInfo.New(Path.Combine(root, "codex-docs-only-test-out")) + }); + var context = new BuildContext(collector, docFs, configurationContext); // --path repo/docs/ now resolves the same checkout as --path repo/: // the docset scan anchors at repo/docs/, FindGitRoot walks one parent to repo/.git @@ -97,24 +93,16 @@ public void PathAndDocsSubfolder_ResolveIdenticalCheckout() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); + var opts = new DocumentationScopeOptions + { + Inner = fs, + Output = fs.DirectoryInfo.New(Path.Combine(root, "codex-equiv-test-out")) + }; - var contextFromRepoRoot = new BuildContext( - collector, - FileSystemFactory.ScopeCurrentWorkingDirectory(fs), - FileSystemFactory.ScopeCurrentWorkingDirectory(fs), - configurationContext, - ExportOptions.Default, - source: repoPath, - output: Path.Combine(root, "codex-equiv-test-out")); - - var contextFromDocsFolder = new BuildContext( - collector, - FileSystemFactory.ScopeCurrentWorkingDirectory(fs), - FileSystemFactory.ScopeCurrentWorkingDirectory(fs), - configurationContext, - ExportOptions.Default, - source: docsPath, - output: Path.Combine(root, "codex-equiv-test-out")); + var fsFromRepoRoot = DocumentationFileSystem.Resolve(fs.DirectoryInfo.New(repoPath), opts); + var fsFromDocsFolder = DocumentationFileSystem.Resolve(fs.DirectoryInfo.New(docsPath), opts); + var contextFromRepoRoot = new BuildContext(collector, fsFromRepoRoot, configurationContext); + var contextFromDocsFolder = new BuildContext(collector, fsFromDocsFolder, configurationContext); contextFromRepoRoot.DocumentationCheckoutDirectory.Should().NotBeNull(); contextFromDocsFolder.DocumentationCheckoutDirectory.Should().NotBeNull(); diff --git a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs index 36b838c9de..18e7469627 100644 --- a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs @@ -9,7 +9,6 @@ using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown.IO; using Elastic.Markdown.Tests.Inline; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.Codex; @@ -20,7 +19,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/r/codex-environments", BuildType = BuildType.Codex @@ -58,7 +57,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs", BuildType = BuildType.Isolated diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs index cc0fc2c822..acb5c13fe7 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -5,6 +5,7 @@ using System.Collections.Frozen; using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.ReleaseNotes; @@ -559,6 +560,9 @@ public class ChangelogCdnInferredProductUnavailableTests(ITestOutputHelper outpu ::: """) { + // Force Unavailable so InferCdnProductFromRepository() returns null — the "could not be inferred" path. + protected override GitCheckoutInformation? GetGitCheckoutInformation() => GitCheckoutInformation.Unavailable; + [Fact] public void EmitsErrorWhenProductCannotBeInferred() { diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index 28c57c3d8d..6add618421 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -10,7 +10,6 @@ using Elastic.Markdown.Myst.Directives; using JetBrains.Annotations; using Markdig.Syntax; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.Directives; @@ -70,12 +69,12 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") var root = FileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/")); // ReSharper disable once VirtualMemberCallInConstructor FileSystem.GenerateDocSetYaml(root, products: GetDocsetProducts(), extraYaml: GetDocsetExtraYaml()); - Collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); // ReSharper disable once VirtualMemberCallInConstructor var environment = GetEnvironment(); - var context = new BuildContext(Collector, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem), configurationContext, environment); + // ReSharper disable once VirtualMemberCallInConstructor + var context = new BuildContext(Collector, TestHelpers.CreateDocumentationFileSystem(FileSystem, root, GetGitCheckoutInformation()), configurationContext, environment); var linkResolver = new TestCrossLinkResolver(); // ReSharper disable once VirtualMemberCallInConstructor Set = new DocumentationSet(context, logger, linkResolver, GetReleaseNotesResolver()); @@ -86,6 +85,12 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") protected virtual void AddToFileSystem(MockFileSystem fileSystem) { } + /// + /// Override to supply an explicit for the build context. + /// Returns by default (factory produces canned test data). + /// + protected virtual GitCheckoutInformation? GetGitCheckoutInformation() => null; + /// /// Override to specify products for the docset configuration. /// Returns null by default (no products configured). diff --git a/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs b/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs index 95dcc9dbea..5331eb018e 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs @@ -2,15 +2,14 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.FileSystems; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.DocSet; @@ -19,16 +18,16 @@ public class NavigationTestsBase : IAsyncLifetime protected NavigationTestsBase(ITestOutputHelper output) { LoggerFactory = new TestLoggerFactory(output); - var mockWriteFs = new MockFileSystem(new MockFileSystemOptions //use in memory mock fs to test generation + var mockWriteFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - ReadFileSystem = FileSystemFactory.RealGitRootForPath(null); - WriteFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockWriteFs); - var collector = new TestDiagnosticsCollector(output); - var configurationContext = TestHelpers.CreateConfigurationContext(ReadFileSystem); var docsTestsPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs-tests"); - var context = new BuildContext(collector, ReadFileSystem, WriteFileSystem, configurationContext, ExportOptions.Default, source: docsTestsPath) + var invocation = new System.IO.Abstractions.FileSystem().DirectoryInfo.New(docsTestsPath); + FileSystem = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { InnerWrite = mockWriteFs }); + var collector = new TestDiagnosticsCollector(output); + var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem.Read); + var context = new BuildContext(collector, FileSystem, configurationContext) { Force = false, UrlPathPrefix = null @@ -43,8 +42,7 @@ protected NavigationTestsBase(ITestOutputHelper output) protected ILoggerFactory LoggerFactory { get; } - protected ScopedFileSystem ReadFileSystem { get; set; } - protected ScopedFileSystem WriteFileSystem { get; set; } + protected DocumentationFileSystem FileSystem { get; } protected DocumentationSet Set { get; } protected DocumentationGenerator Generator { get; } protected ConfigurationFile? Configuration { get; set; } diff --git a/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs b/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs index 9cc7745dd9..0285d0ce59 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs @@ -7,9 +7,9 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Markdown.IO; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.DocSet; @@ -36,12 +36,12 @@ public ReportIssueUrlTests(ITestOutputHelper output) { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - var readFileSystem = FileSystemFactory.RealGitRootForPath(null); - var writeFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockWriteFs); + var invocation = new System.IO.Abstractions.FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var fs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { InnerWrite = mockWriteFs }); var collector = new TestDiagnosticsCollector(output); - var configurationContext = TestHelpers.CreateConfigurationContext(readFileSystem); + var configurationContext = TestHelpers.CreateConfigurationContext(fs.Read); - var context = new BuildContext(collector, readFileSystem, writeFileSystem, configurationContext, ExportOptions.Default) + var context = new BuildContext(collector, fs, configurationContext) { Force = false, UrlPathPrefix = UrlPathPrefix, diff --git a/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs b/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs index 21ccbf2707..823e27ddc0 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs @@ -34,8 +34,8 @@ public class GitCheckoutInformationTests(ITestOutputHelper output) : NavigationT [Fact] public void Create() { - var root = ReadFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); - var git = GitCheckoutInformationFactory.Create(root, ReadFileSystem, LoggerFactory.CreateLogger(nameof(GitCheckoutInformation))); + var root = FileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var git = GitCheckoutInformationFactory.Create(root, FileSystem.Read, LoggerFactory.CreateLogger(nameof(GitCheckoutInformation))); git.Should().NotBeNull(); git.Branch.Should().NotBeNullOrWhiteSpace(); diff --git a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs index 07b4a2cd26..5eb70a966b 100644 --- a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs @@ -15,7 +15,6 @@ using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.InlineParsers; using Elastic.Markdown.Tests; -using Nullean.ScopedFileSystem; using Xunit; namespace Elastic.Markdown.Tests.Inline; @@ -93,7 +92,7 @@ private async Task ResolveUrlForBuildMode(string relativeAssetPath, Bu _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var buildContext = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + var buildContext = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs", BuildType = buildType diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index a42ee0ea4e..bbc860da2e 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -10,7 +10,6 @@ using JetBrains.Annotations; using Markdig.Syntax; using Markdig.Syntax.Inlines; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.Inline; @@ -133,7 +132,7 @@ protected virtual BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs" }; diff --git a/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs b/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs index 4307bed63c..ba81aeb26f 100644 --- a/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs +++ b/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs @@ -7,7 +7,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests; @@ -36,7 +35,7 @@ public void TocReferencesMissingFile_DoesNotThrow_AndEmitsClearError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); var act = () => _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); diff --git a/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs b/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs index 6d4e9221fc..682c5815cf 100644 --- a/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs +++ b/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs @@ -18,7 +18,7 @@ public async Task RelativeLinks() var workingDirectory = Set.Configuration.SourceFile.DirectoryName; Directory.SetCurrentDirectory(workingDirectory!); - var mover = new Move(LoggerFactory, ReadFileSystem, WriteFileSystem, Set); + var mover = new Move(LoggerFactory, FileSystem.Read, FileSystem.Write, Set); await mover.Execute("mover/first-page.md", "new-folder/hello-world.md", true, TestContext.Current.CancellationToken); mover.Changes.Should().HaveCount(1); @@ -47,7 +47,7 @@ public async Task MoveToFolder() var workingDirectory = Set.Configuration.SourceFile.DirectoryName; Directory.SetCurrentDirectory(workingDirectory!); - var mover = new Move(LoggerFactory, ReadFileSystem, WriteFileSystem, Set); + var mover = new Move(LoggerFactory, FileSystem.Read, FileSystem.Write, Set); await mover.Execute("mover/first-page.md", "new-folder", true, TestContext.Current.CancellationToken); mover.Changes.Should().HaveCount(1); @@ -75,7 +75,7 @@ public async Task MoveFolderToFolder() var workingDirectory = Set.Configuration.SourceFile.DirectoryName; Directory.SetCurrentDirectory(workingDirectory!); - var mover = new Move(LoggerFactory, ReadFileSystem, WriteFileSystem, Set); + var mover = new Move(LoggerFactory, FileSystem.Read, FileSystem.Write, Set); await mover.Execute("mover", "new-folder", true, TestContext.Current.CancellationToken); mover.Changes.Should().HaveCount(2); diff --git a/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs b/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs index d6dfa1295c..fab4e2cedc 100644 --- a/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs +++ b/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs @@ -7,7 +7,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests; @@ -33,7 +32,7 @@ public async Task CreatesDefaultOutputDirectory() }); await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); var linkResolver = new TestCrossLinkResolver(); var set = new DocumentationSet(context, logger, linkResolver); var generator = new DocumentationGenerator(set, logger); @@ -66,7 +65,7 @@ public void FilesWithSnippetsInNameNotTreatedAsSnippets() }); var collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); var linkResolver = new TestCrossLinkResolver(); var set = new DocumentationSet(context, logger, linkResolver); diff --git a/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs b/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs index 4c29e609e7..20cf1f4b74 100644 --- a/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs +++ b/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs @@ -8,7 +8,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests; @@ -34,7 +33,7 @@ public void InternalRegistry_MissingIndexMd_EmitsError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); collector.Errors.Should().BeGreaterThan(0); @@ -65,7 +64,7 @@ public void InternalRegistry_WithIndexMd_NoError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); collector.Diagnostics @@ -93,7 +92,7 @@ public void PublicRegistry_MissingIndexMd_NoError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); collector.Diagnostics diff --git a/tests/Elastic.Markdown.Tests/TestHelpers.cs b/tests/Elastic.Markdown.Tests/TestHelpers.cs index 2aa37abeab..5f14bc07c1 100644 --- a/tests/Elastic.Markdown.Tests/TestHelpers.cs +++ b/tests/Elastic.Markdown.Tests/TestHelpers.cs @@ -4,18 +4,39 @@ using System.Collections.Frozen; using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; namespace Elastic.Markdown.Tests; public static class TestHelpers { + /// + /// Resolves a over for tests + /// that only need a scoped FS without real git data. Ensures a stub .git directory exists + /// at the working-directory root (no config file) so + /// GitCheckoutInformationFactory.IsLegacyTestWithoutGitLayout returns the well-known + /// canned test instance instead of failing. + /// + public static DocumentationFileSystem CreateDocumentationFileSystem( + MockFileSystem fileSystem, + IDirectoryInfo? invocation = null, + GitCheckoutInformation? git = null) + { + var gitPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".git"); + if (!fileSystem.Directory.Exists(gitPath)) + fileSystem.Directory.CreateDirectory(gitPath); + invocation ??= fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs")); + return DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { Inner = fileSystem, Git = git }); + } + public static IConfigurationContext CreateConfigurationContext(IFileSystem fileSystem, VersionsConfiguration? versionsConfiguration = null, ProductsConfiguration? productsConfiguration = null) { versionsConfiguration ??= new VersionsConfiguration diff --git a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs index ea50bacdde..f932f80130 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Isolated.Node; using Nullean.ScopedFileSystem; @@ -80,7 +81,7 @@ internal sealed class TestCodexDocumentationContext(IDiagnosticsCollector collec public IFileInfo ConfigurationPath => _fileSystem.FileInfo.New(_fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); public IDiagnosticsCollector Collector => collector; public ScopedFileSystem ReadFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectory(_fileSystem); - public ScopedFileSystem WriteFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(_fileSystem); + public DocumentationWriteFileSystem WriteFileSystem => new(_fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fileSystem); public IDirectoryInfo OutputDirectory => _fileSystem.DirectoryInfo.New(_fileSystem.Path.Join(Paths.ApplicationData.FullName, "codex", "output")); public BuildType BuildType => BuildType.Codex; diff --git a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs index f692922cdd..1941a1ed3f 100644 --- a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs +++ b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Codex.Navigation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -139,7 +140,7 @@ private sealed class MinimalCodexContext : ICodexDocumentationContext public System.IO.Abstractions.IFileInfo ConfigurationPath => _fs.FileInfo.New("/codex.yml"); public Elastic.Documentation.Diagnostics.IDiagnosticsCollector Collector => new Elastic.Documentation.Diagnostics.DiagnosticsCollector([]); public ScopedFileSystem ReadFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectory(_fs); - public ScopedFileSystem WriteFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(_fs); + public DocumentationWriteFileSystem WriteFileSystem => new(_fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fs); public System.IO.Abstractions.IDirectoryInfo OutputDirectory => _fs.DirectoryInfo.New("/output"); public BuildType BuildType => BuildType.Codex; public void EmitError(string message) { } diff --git a/tests/Navigation.Tests/TestDocumentationSetContext.cs b/tests/Navigation.Tests/TestDocumentationSetContext.cs index 37e5458055..983c94c8df 100644 --- a/tests/Navigation.Tests/TestDocumentationSetContext.cs +++ b/tests/Navigation.Tests/TestDocumentationSetContext.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Navigation.Isolated; using Markdig; @@ -84,7 +85,7 @@ public TestDocumentationSetContext(IFileSystem fileSystem, ) { ReadFileSystem = FileSystemFactory.ScopeSourceDirectory(fileSystem, sourceDirectory.FullName); - WriteFileSystem = FileSystemFactory.ScopeSourceDirectoryForWrite(fileSystem, outputDirectory.FullName); + WriteFileSystem = new DocumentationWriteFileSystem(sourceDirectory, outputDirectory, fileSystem); DocumentationSourceDirectory = sourceDirectory; OutputDirectory = outputDirectory; ConfigurationPath = configPath; @@ -103,7 +104,7 @@ public TestDocumentationSetContext(IFileSystem fileSystem, public IDiagnosticsCollector Collector { get; } public ScopedFileSystem ReadFileSystem { get; } - public ScopedFileSystem WriteFileSystem { get; } + public DocumentationWriteFileSystem WriteFileSystem { get; } public IDirectoryInfo OutputDirectory { get; } public IDirectoryInfo DocumentationSourceDirectory { get; } public GitCheckoutInformation Git { get; } diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs index 53b55f939f..1adb5e0215 100644 --- a/tests/authoring/Framework/CrossLinkResolverAssertions.fs +++ b/tests/authoring/Framework/CrossLinkResolverAssertions.fs @@ -15,6 +15,7 @@ open Elastic.Documentation open Swensen.Unquote open Elastic.Documentation.Configuration open Elastic.Documentation.Configuration.Builder +open Elastic.Documentation.FileSystems open authoring module CrossLinkResolverAssertions = @@ -32,7 +33,7 @@ module CrossLinkResolverAssertions = member _.DocumentationSourceDirectory = mockFileSystem.DirectoryInfo.New("/docs") member _.Git = GitCheckoutInformation.Unavailable member _.ReadFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFileSystem) - member _.WriteFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFileSystem) + member _.WriteFileSystem = DocumentationWriteFileSystem(mockFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, mockFileSystem) member _.ConfigurationPath = mockFileSystem.FileInfo.New("mock_docset.yml") member _.OutputDirectory = mockFileSystem.DirectoryInfo.New(".artifacts") member _.BuildType = BuildType.Isolated diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index 96605871f5..00a04d973e 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -9,10 +9,12 @@ open System open System.Collections.Frozen open System.Collections.Generic open System.IO +open System.IO.Abstractions open System.IO.Abstractions.TestingHelpers open System.Threading.Tasks open YamlDotNet.RepresentationModel open Elastic.Documentation +open Elastic.Documentation.FileSystems open Elastic.Documentation.Versions open Elastic.Documentation.Configuration open Elastic.Documentation.Configuration.LegacyUrlMappings @@ -479,9 +481,18 @@ type Setup = LegacyUrlMappings = LegacyUrlMappingConfiguration(Mappings = []), SearchConfiguration = SearchConfiguration(Synonyms = Array.empty, Rules = [], DiminishTerms = []) ) + // A bare .git directory (no config) anchors FindGitRoot so checkout = WorkingDirectoryRoot, + // which makes OutputDirectory = WorkingDirectoryRoot/.artifacts/docs/html (not docs/.artifacts). + // The missing config triggers IsLegacyTestWithoutGitLayout → canned data, but we override that + // with Git = Unavailable because authoring tests exercise rendering, not real git metadata. + let gitPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".git") + if not (fileSystem.Directory.Exists gitPath) then + fileSystem.Directory.CreateDirectory gitPath |> ignore + let invocation = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs/")) + let docFs = DocumentationFileSystem.Resolve(invocation, DocumentationScopeOptions(Inner = (fileSystem :> IFileSystem), Git = GitCheckoutInformation.Unavailable)) let context = BuildContext( collector, - FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), + docFs, configurationContext, UrlPathPrefix = (options.UrlPathPrefix |> Option.defaultValue ""), CanonicalBaseUrl = Uri("https://www.elastic.co/") From e809fb4cacc69505f778b15d466efdd6b4e977e4 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 7 Aug 2026 12:49:00 +0200 Subject: [PATCH 06/29] Bump Nullean.ScopedFileSystem to 0.4.2 (released); remove local nuget source 0.4.2 ships the TryValidateSymlinkAccess early-exit fix from nullean/scoped-filesystem#12 as a proper release. The canary pin and nuget.config local-dev source are no longer needed. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi --- Directory.Packages.props | 2 +- nuget.config | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 nuget.config diff --git a/Directory.Packages.props b/Directory.Packages.props index ccc18b07e9..910facdae0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,7 +45,7 @@ - + diff --git a/nuget.config b/nuget.config deleted file mode 100644 index 36c8191882..0000000000 --- a/nuget.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - From d5e8162349ddaf23f489b61218f6d3b414da41f0 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 7 Aug 2026 13:13:17 +0200 Subject: [PATCH 07/29] Fix CheckoutsFileSystem disjointness when checkouts live inside AppData On CI the checkouts directory is stored inside the OS application-data directory (e.g. /home/runner/.local/share/elastic/docs-builder/checkouts/ nested inside /home/runner/.local/share/elastic/docs-builder), so adding both as ScopedFileSystem roots throws 'Scope roots must be disjoint'. Apply the same ancestor/descendant filter to the AppData root that was already applied to extraRoots: if AppData is a parent or child of the checkouts root, skip it (the narrower root already covers the needed access). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi --- .../FileSystems/CheckoutsFileSystem.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs index c244e8ec29..632bc7adfa 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs @@ -47,7 +47,14 @@ private static ScopedFileSystemOptions BuildReadOptions( IEnumerable? extraRoots) { var rootPath = root.FullName; - var roots = new List { rootPath, Paths.ApplicationData.FullName }; + var roots = new List { rootPath }; + + // AppData is disjointness-filtered too: on CI the checkouts directory lives inside AppData + // (/home/runner/.local/share/elastic/docs-builder/checkouts/...), so AppData would subsume + // root and the ScopedFileSystem constructor would throw. + var appData = Paths.ApplicationData.FullName; + if (!IsSubPath(appData, rootPath) && !IsSubPath(rootPath, appData)) + roots.Add(appData); if (extraRoots is not null) { From 344959b0cd86c13cebcaf509c3adfe961d254a11 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 7 Aug 2026 13:29:19 +0200 Subject: [PATCH 08/29] Migrate Codex/Assembler CLI commands from FileSystemFactory to CheckoutsFileSystem - CodexCommands (CloneAndBuild, Clone, Build): replace bare ScopeCurrentWorkingDirectory with CheckoutsFileSystem anchored at WorkingDirectoryRoot, config's git root as extraRoot - CodexCommands.Serve: remove filesystem entirely; use Path.Join directly (no file I/O) - CodexIndexCommand: same CheckoutsFileSystem migration - CodexSyncCommand.LoadContext: replace RealRead with CheckoutsFileSystem, add config git root as extraRoot so --config outside CWD is no longer silently out of scope - CodexUpdateRedirectsCommand: same CheckoutsFileSystem migration - DeployCommands.Plan/Apply: single CheckoutsFileSystem.FromWorkingDirectory() per method instead of two separate instances (one for .Read, one for .Write) - DeployCommands.UpdateRedirects: RealRead -> CheckoutsFileSystem.FromWorkingDirectory() - ConfigurationCommands.Init (assembler init): RealRead -> CheckoutsFileSystem.FromWorkingDirectory() Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi --- .../Assembler/ConfigurationCommands.cs | 5 +-- .../Commands/Assembler/DeployCommands.cs | 9 ++-- .../Commands/Codex/CodexCommands.cs | 44 +++++++++---------- .../Commands/Codex/CodexIndexCommand.cs | 15 ++++--- .../Commands/Codex/CodexSyncCommand.cs | 12 +++-- .../Codex/CodexUpdateRedirectsCommand.cs | 12 +++-- 6 files changed, 53 insertions(+), 44 deletions(-) diff --git a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs index e617afd6d5..88491515c4 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs @@ -2,12 +2,12 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Assembler.Configuration; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -34,8 +34,7 @@ public async Task Init(string? gitRef = null, bool local = false, Cancellat { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealRead; - var service = new ConfigurationCloneService(logFactory, assemblyConfiguration, fs); + var service = new ConfigurationCloneService(logFactory, assemblyConfiguration, CheckoutsFileSystem.FromWorkingDirectory()); serviceInvoker.AddCommand(service, (gitRef, local), static async (s, collector, state, ctx) => await s.InitConfigurationToApplicationData(collector, state.gitRef, state.local, ctx)); return await serviceInvoker.InvokeAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index b019c7fe9c..392d8c0e51 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -44,7 +44,8 @@ public async Task Plan(string environment, string s3BucketName, [ExpandUser { await using var serviceInvoker = new ServiceInvoker(collector); - var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, CheckoutsFileSystem.FromWorkingDirectory().Read, CheckoutsFileSystem.FromWorkingDirectory().Write, null, null); + var fs = CheckoutsFileSystem.FromWorkingDirectory(); + var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs.Read, fs.Write, null, null); var service = new IncrementalDeployService(logFactory, githubActionsService); serviceInvoker.AddCommand(service, (context, s3BucketName, @out, deleteThreshold), static async (s, collector, state, ctx) => await s.Plan(collector, state.context, state.s3BucketName, state.@out?.FullName ?? "", state.deleteThreshold, [], ctx) @@ -65,7 +66,8 @@ public async Task Apply(string environment, string s3BucketName, [Existing, { await using var serviceInvoker = new ServiceInvoker(collector); - var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, CheckoutsFileSystem.FromWorkingDirectory().Read, CheckoutsFileSystem.FromWorkingDirectory().Write, null, null); + var fs = CheckoutsFileSystem.FromWorkingDirectory(); + var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs.Read, fs.Write, null, null); var service = new IncrementalDeployService(logFactory, githubActionsService); serviceInvoker.AddCommand(service, (context, s3BucketName, planFile), static async (s, collector, state, ctx) => await s.Apply(collector, state.context, state.s3BucketName, state.planFile.FullName, ctx) @@ -82,8 +84,7 @@ public async Task UpdateRedirects(string environment, [Existing, ExpandUser { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealRead; - var service = new DeployUpdateRedirectsService(logFactory, fs); + var service = new DeployUpdateRedirectsService(logFactory, CheckoutsFileSystem.FromWorkingDirectory()); serviceInvoker.AddCommand(service, (environment, redirectsFile), static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile?.FullName, ctx: ctx) ); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index bf6be5080d..7a83bae01e 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -60,18 +60,18 @@ public async Task CloneAndBuild( { await using var serviceInvoker = new ServiceInvoker(collector); var plain = new FileSystem(); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, - [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); - var writeFs = new DocumentationWriteFileSystem( + var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; + var fs = new CheckoutsFileSystem( plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), output is null ? null : plain.DirectoryInfo.New(output.FullName), - plain); + extraRoots: [gitRoot], + inner: plain); - var configFile = readFs.FileInfo.New(config.FullName); + var configFile = fs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, writeFs, null, output?.FullName); + var codexContext = new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, output?.FullName); using var linkIndexReader = new GitLinkIndexReader(environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); @@ -86,7 +86,7 @@ public async Task CloneAndBuild( var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var buildService = new CodexBuildService(logFactory, configurationContext, isolatedBuildService); - serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, readFs), strict, + serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, readFs: fs.Read), strict, async (s, col, state, c) => { if (state.cloneResult == null) @@ -123,16 +123,17 @@ public async Task Clone( { await using var serviceInvoker = new ServiceInvoker(collector); var plain = new FileSystem(); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, - [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); + var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; + var fs = new CheckoutsFileSystem( + plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + extraRoots: [gitRoot], + inner: plain); - var configFile = readFs.FileInfo.New(config.FullName); + var configFile = fs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, - new DocumentationWriteFileSystem(plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, plain), - null, null); + var codexContext = new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, null); using var linkIndexReader = new GitLinkIndexReader(environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); @@ -161,18 +162,18 @@ public async Task Build( { await using var serviceInvoker = new ServiceInvoker(collector); var plain = new FileSystem(); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, - [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); - var writeFs = new DocumentationWriteFileSystem( + var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; + var fs = new CheckoutsFileSystem( plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), output is null ? null : plain.DirectoryInfo.New(output.FullName), - plain); + extraRoots: [gitRoot], + inner: plain); - var configFile = readFs.FileInfo.New(config.FullName); + var configFile = fs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out _)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, writeFs, null, output?.FullName); + var codexContext = new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, output?.FullName); var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); if (cloneResult == null || cloneResult.Checkouts.Count == 0) @@ -183,7 +184,7 @@ public async Task Build( var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var buildService = new CodexBuildService(logFactory, configurationContext, isolatedBuildService); - serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, readFs), strict, + serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, readFs: fs.Read), strict, async (s, col, state, c) => { var result = await s.BuildAll(state.codexContext, state.cloneResult, state.readFs, c); @@ -201,8 +202,7 @@ public async Task Build( [NoOptionsInjection] public async Task Serve(int port = 4000, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, CancellationToken ct = default) { - var fs = FileSystemFactory.RealRead; - var servePath = path?.FullName ?? fs.Path.Join(Environment.CurrentDirectory, ".artifacts", "codex", "docs"); + var servePath = path?.FullName ?? Path.Join(Environment.CurrentDirectory, ".artifacts", "codex", "docs"); var host = new StaticWebHost(port, servePath); await host.RunAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index b8dec8a4ef..212ff706ec 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -45,15 +45,16 @@ public async Task Index( { await using var serviceInvoker = new ServiceInvoker(collector); var plain = new FileSystem(); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, - [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); - var configFile = readFs.FileInfo.New(config.FullName); + var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; + var fs = new CheckoutsFileSystem( + plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + extraRoots: [gitRoot], + inner: plain); + var configFile = fs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, - new DocumentationWriteFileSystem(plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, plain), - null, null); + var codexContext = new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, null); var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); @@ -65,7 +66,7 @@ public async Task Index( var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var service = new CodexIndexService(logFactory, configurationContext, isolatedBuildService); - serviceInvoker.AddCommand(service, (codexContext, cloneResult, readFs, es), + serviceInvoker.AddCommand(service, (codexContext, cloneResult, readFs: fs.Read, es), static async (s, col, state, c) => await s.Index(state.codexContext, state.cloneResult, state.readFs, state.es, c) ); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs index ed50015ff6..2b22729fa8 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.ComponentModel.DataAnnotations; +using System.IO.Abstractions; using Actions.Core.Services; using Elastic.Codex; using Elastic.Documentation; @@ -86,12 +87,15 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex private (CodexContext context, IncrementalDeployService service) LoadContext(FileInfo config) { - var fs = FileSystemFactory.RealRead; + var plain = new FileSystem(); + var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; + var fs = new CheckoutsFileSystem( + plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + extraRoots: [gitRoot], + inner: plain); var configFile = fs.FileInfo.New(config.FullName); var codexConfig = CodexConfiguration.Load(configFile); - var writeFs = new DocumentationWriteFileSystem( - fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, null); - return (new CodexContext(codexConfig, configFile, collector, fs, writeFs, null, null), + return (new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, null), new IncrementalDeployService(logFactory, githubActionsService)); } } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index 7afd9af5c9..6eb4139a49 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Assembler.Deploying; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -36,9 +37,12 @@ public async Task UpdateRedirects( await using var serviceInvoker = new ServiceInvoker(collector); var plain = new FileSystem(); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(plain, - [Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]); - var configFile = readFs.FileInfo.New(config.FullName); + var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; + var fs = new CheckoutsFileSystem( + plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + extraRoots: [gitRoot], + inner: plain); + var configFile = fs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig)) return 1; @@ -47,7 +51,7 @@ public async Task UpdateRedirects( ?? Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "internal"; - var service = new DeployUpdateRedirectsService(logFactory, readFs); + var service = new DeployUpdateRedirectsService(logFactory, fs.Read); serviceInvoker.AddCommand(service, (environment: resolvedEnvironment, redirectsFile, kvsNamePrefix: "codex", defaultRedirectsFile: ".artifacts/codex/docs/redirects.json"), static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile?.FullName, state.kvsNamePrefix, state.defaultRedirectsFile, c) ); From e0d06514dcd03de3590c301c00be763e3ea3f8d4 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 7 Aug 2026 14:26:38 +0200 Subject: [PATCH 09/29] Fix DocumentationFileSystem AppData disjointness on CI Same issue as CheckoutsFileSystem: on CI each docset checkout lives inside AppData (/home/runner/.local/share/.../checkouts/current/), so unconditionally adding AppData as a second scope root trips ValidateRootsAreDisjoint when checkout is a sub-path of AppData. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi --- .../FileSystems/DocumentationFileSystem.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs index 3126dfb833..f43168b2c7 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs @@ -74,7 +74,14 @@ public static DocumentationFileSystem Resolve( private static ScopedFileSystemOptions BuildReadOptions(ResolvedDocumentationPaths paths) { var checkoutPath = paths.CheckoutDirectory.FullName; - var roots = new List { checkoutPath, Configuration.Paths.ApplicationData.FullName }; + var roots = new List { checkoutPath }; + + // AppData is disjointness-filtered: on CI each individual docset checkout lives inside AppData + // (/home/runner/.local/share/elastic/docs-builder/checkouts/current/), so AppData would + // subsume checkoutPath and trigger ValidateRootsAreDisjoint. + var appData = Configuration.Paths.ApplicationData.FullName; + if (!IsSubPath(appData, checkoutPath) && !IsSubPath(checkoutPath, appData)) + roots.Add(appData); foreach (var gitDir in paths.GitDirectories) { From 0e6a418dd25fb4bc08ddee293be289de8d2dcf77 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 7 Aug 2026 15:01:06 +0200 Subject: [PATCH 10/29] Fix DocumentationWriteFileSystem AppData disjointness on CI Same issue as DocumentationFileSystem and CheckoutsFileSystem: the write scope was also unconditionally adding AppData alongside the checkout root. On CI each docset checkout lives inside AppData, so they are in a parent/child relationship and ValidateRootsAreDisjoint throws. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi --- .../FileSystems/DocumentationWriteFileSystem.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs index 1042ec297e..2760bebc84 100644 --- a/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs +++ b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs @@ -58,7 +58,14 @@ private static ScopedFileSystemOptions BuildOptions( string? outputPath, IFileSystem? inner) { - var roots = new List { checkoutPath, ApplicationDataPath }; + var roots = new List { checkoutPath }; + + // AppData is disjointness-filtered: on CI each docset checkout lives inside AppData + // (/home/runner/.local/share/elastic/docs-builder/checkouts/current/), so AppData would + // subsume checkoutPath and trigger ValidateRootsAreDisjoint. + var appData = ApplicationDataPath; + if (!IsSubPath(appData, checkoutPath) && !IsSubPath(checkoutPath, appData)) + roots.Add(appData); if (outputPath is not null) { @@ -93,4 +100,10 @@ private static ScopedFileSystemOptions BuildOptions( AllowedSpecialFolders = AllowedSpecialFolder.Temp }; } + + private static bool IsSubPath(string path, string parent) + { + var sep = System.IO.Path.DirectorySeparatorChar; + return (path.TrimEnd(sep) + sep).StartsWith(parent.TrimEnd(sep) + sep, StringComparison.OrdinalIgnoreCase); + } } From 0f62895131d74b3c1efe795ce5625a125a4ce43b Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 7 Aug 2026 17:10:25 +0200 Subject: [PATCH 11/29] Add CI checkout-inside-AppData regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three filesystem types each get tests for the GitHub Actions hosted-runner layout where checkout ⊂ AppData (~/.local/share/elastic/docs-builder/checkouts/current/): - CheckoutsFileSystem: construction and file read - DocumentationWriteFileSystem: construction and directory create - DocumentationFileSystem: Resolve, checkout path resolution, .Write access Each test uses a checkout path derived from the real Paths.ApplicationData so the same layout that kills CI is exercised locally on every platform. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi --- .../CiCheckoutLayoutTests.cs | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs diff --git a/tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs new file mode 100644 index 0000000000..9acb0e0caf --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs @@ -0,0 +1,173 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; + +namespace Elastic.Documentation.Configuration.Tests; + +/// +/// Regression tests for the GitHub Actions hosted-runner layout where each documentation +/// set checkout lives inside the ApplicationData folder: +/// ~/.local/share/elastic/docs-builder/checkouts/current/<repo> +/// +/// Before the fix, all three filesystem types unconditionally added ApplicationData as a +/// second scope root alongside the checkout. When checkout ⊂ AppData, the two roots form +/// a parent–child pair and ValidateRootsAreDisjoint throws with "Scope roots must +/// be disjoint". Now each type skips AppData when checkout is a sub-path of it (or vice +/// versa). +/// +/// +public class CiCheckoutLayoutTests +{ + /// + /// Constructs the simulated CI checkout path: a directory nested inside the real + /// ApplicationData folder, matching the layout the assembler uses on hosted runners. + /// + private static string CiCheckoutRoot => + Path.Join(Paths.ApplicationData.FullName, "checkouts", "current", "apm-server"); + + // ----------------------------------------------------------------------- + // CheckoutsFileSystem + // ----------------------------------------------------------------------- + + [Fact] + public void CheckoutsFileSystem_CheckoutInsideAppData_DoesNotThrow() + { + var checkoutRoot = CiCheckoutRoot; + var mockFs = new MockFileSystem(new Dictionary + { + { Path.Join(checkoutRoot, "readme.md"), new MockFileData("hello") } + }); + + var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(checkoutRoot), inner: mockFs); + + act.Should().NotThrow(); + } + + [Fact] + public void CheckoutsFileSystem_CheckoutInsideAppData_ReadsFilesUnderCheckout() + { + var checkoutRoot = CiCheckoutRoot; + var filePath = Path.Join(checkoutRoot, "readme.md"); + var mockFs = new MockFileSystem(new Dictionary + { + { filePath, new MockFileData("hello") } + }); + + var fs = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(checkoutRoot), inner: mockFs); + + fs.File.Exists(filePath).Should().BeTrue(); + } + + // ----------------------------------------------------------------------- + // DocumentationWriteFileSystem + // ----------------------------------------------------------------------- + + [Fact] + public void DocumentationWriteFileSystem_CheckoutInsideAppData_DoesNotThrow() + { + var checkoutRoot = CiCheckoutRoot; + var mockFs = new MockFileSystem(); + + var act = () => new DocumentationWriteFileSystem( + mockFs.DirectoryInfo.New(checkoutRoot), + inner: mockFs); + + act.Should().NotThrow(); + } + + [Fact] + public void DocumentationWriteFileSystem_CheckoutInsideAppData_WritesFilesUnderCheckout() + { + var checkoutRoot = CiCheckoutRoot; + var outputPath = Path.Join(checkoutRoot, ".artifacts", "docs", "html"); + var mockFs = new MockFileSystem(); + + var writeFs = new DocumentationWriteFileSystem( + mockFs.DirectoryInfo.New(checkoutRoot), + inner: mockFs); + + var act = () => writeFs.Directory.CreateDirectory(outputPath); + act.Should().NotThrow(); + } + + // ----------------------------------------------------------------------- + // DocumentationFileSystem (read + write via Resolve) + // ----------------------------------------------------------------------- + + [Fact] + public void DocumentationFileSystem_Resolve_CheckoutInsideAppData_DoesNotThrow() + { + var checkoutRoot = CiCheckoutRoot; + var docsPath = Path.Join(checkoutRoot, "docs"); + var mockFs = BuildDocsetFs(checkoutRoot, docsPath); + + var act = () => DocumentationFileSystem.Resolve( + mockFs.DirectoryInfo.New(docsPath), + new DocumentationScopeOptions { Inner = mockFs }); + + act.Should().NotThrow(); + } + + [Fact] + public void DocumentationFileSystem_Resolve_CheckoutInsideAppData_CheckoutResolvedCorrectly() + { + var checkoutRoot = CiCheckoutRoot; + var docsPath = Path.Join(checkoutRoot, "docs"); + var mockFs = BuildDocsetFs(checkoutRoot, docsPath); + + var docFs = DocumentationFileSystem.Resolve( + mockFs.DirectoryInfo.New(docsPath), + new DocumentationScopeOptions { Inner = mockFs }); + + docFs.Paths.CheckoutDirectory.FullName.Should().Be(checkoutRoot); + } + + [Fact] + public void DocumentationFileSystem_Resolve_CheckoutInsideAppData_WriteDoesNotThrow() + { + var checkoutRoot = CiCheckoutRoot; + var docsPath = Path.Join(checkoutRoot, "docs"); + var mockFs = BuildDocsetFs(checkoutRoot, docsPath); + + var act = () => + { + var docFs = DocumentationFileSystem.Resolve( + mockFs.DirectoryInfo.New(docsPath), + new DocumentationScopeOptions { Inner = mockFs }); + // accessing .Write must not throw either + _ = docFs.Write; + }; + + act.Should().NotThrow(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private static MockFileSystem BuildDocsetFs(string checkoutRoot, string docsPath) + { + var mockFs = new MockFileSystem(); + mockFs.AddDirectory(Path.Join(checkoutRoot, ".git")); + mockFs.AddFile(Path.Join(checkoutRoot, ".git", "HEAD"), + new MockFileData("ref: refs/heads/main\n")); + mockFs.AddFile(Path.Join(checkoutRoot, ".git", "refs", "heads", "main"), + new MockFileData("abc1234\n")); + mockFs.AddFile(Path.Join(checkoutRoot, ".git", "config"), + new MockFileData(""" + [remote "origin"] + url = https://github.com/elastic/apm-server.git + [branch "main"] + remote = origin + merge = refs/heads/main + """)); + mockFs.AddFile(Path.Join(docsPath, "docset.yml"), new MockFileData("toc: []\n")); + return mockFs; + } +} From 57a4649a30804611155bdaf2bcb403cdf804b03e Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 7 Aug 2026 17:32:33 +0200 Subject: [PATCH 12/29] Fix test failures on Windows and CI DocumentationPathsResolverTests: hardcoded Unix path literals (/repo/docs) fail on Windows because MockFileSystem normalises them to C:\repo\docs. Add a P(fs, path) helper that normalises via the mock filesystem and apply it to every path assertion. GitCheckoutResolutionTests.RegularRepo_DetachedHead_NeverReturnsRandomGuid: production code falls through to GITHUB_PR_REF_NAME ?? GITHUB_REF_NAME ?? 'detached/head' for detached HEAD. On CI GITHUB_REF_NAME=3789/merge, so the assertion fails. Mirror the same env-variable lookup in the expected value so the test always matches. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi --- .../DocumentationPathsResolverTests.cs | 45 +++++++++++-------- .../GitCheckoutResolutionTests.cs | 7 ++- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs index 950425041d..94ca95c091 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs @@ -21,6 +21,13 @@ public class DocumentationPathsResolverTests // Helpers // ----------------------------------------------------------------------- + /// + /// Normalises a Unix-style path through the mock filesystem so that assertions work on + /// Windows, where MockFileSystem converts /repoC:\repo. + /// + private static string P(MockFileSystem fs, string unixPath) => + fs.DirectoryInfo.New(unixPath).FullName; + /// /// Builds a minimal regular-repo filesystem: /// /repo/.git/{HEAD,config,refs/...} + @@ -101,8 +108,8 @@ public void InvocationAtRepoRoot_ResolvesDocsetInDocsSubfolder() var paths = DocumentationPathsResolver.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }, fs); - paths.SourceDirectory.FullName.Should().Be("/repo/docs"); - paths.CheckoutDirectory.FullName.Should().Be("/repo"); + paths.SourceDirectory.FullName.Should().Be(P(fs, "/repo/docs")); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo")); } [Fact] @@ -113,8 +120,8 @@ public void InvocationAtDocsSubfolder_ResolvesDocsetAndCheckout() var paths = DocumentationPathsResolver.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }, fs); - paths.SourceDirectory.FullName.Should().Be("/repo/docs"); - paths.CheckoutDirectory.FullName.Should().Be("/repo"); + paths.SourceDirectory.FullName.Should().Be(P(fs, "/repo/docs")); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo")); } [Fact] @@ -144,8 +151,8 @@ public void InvocationPath_StoredVerbatim_IndependentOfCheckout() var paths = DocumentationPathsResolver.Resolve(invocationDir, new DocumentationScopeOptions { Inner = fs }, fs); - paths.InvocationPath.FullName.Should().Be("/repo/docs"); - paths.CheckoutDirectory.FullName.Should().Be("/repo"); + paths.InvocationPath.FullName.Should().Be(P(fs, "/repo/docs")); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo")); } // ----------------------------------------------------------------------- @@ -162,7 +169,7 @@ public void RegularRepo_GitDirectories_ContainsOneEntry() new DocumentationScopeOptions { Inner = fs }, fs); paths.GitDirectories.Should().ContainSingle() - .Which.Should().Be("/repo/.git"); + .Which.Should().Be(P(fs, "/repo/.git")); } [Fact] @@ -193,7 +200,7 @@ public void Worktree_CheckoutIsWorktreeRoot_NotMainRepo() fs.DirectoryInfo.New("/worktree"), new DocumentationScopeOptions { Inner = fs }, fs); - paths.CheckoutDirectory.FullName.Should().Be("/worktree"); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/worktree")); } [Fact] @@ -206,9 +213,9 @@ public void Worktree_GitDirectories_ContainsPointerAndMainGit() new DocumentationScopeOptions { Inner = fs }, fs); paths.GitDirectories.Should().HaveCount(2); - paths.GitDirectories.Should().Contain("/worktree/.git", + paths.GitDirectories.Should().Contain(P(fs, "/worktree/.git"), "pointer file path must be in scope so the .git file is readable"); - paths.GitDirectories.Should().Contain("/main/.git", + paths.GitDirectories.Should().Contain(P(fs, "/main/.git"), "resolved commondir target must be included so config/HEAD are readable"); } @@ -273,7 +280,7 @@ public void ExplicitGitDir_CheckoutIsGitDirParent() var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project/docs"), opts, fs); - paths.CheckoutDirectory.FullName.Should().Be("/repo", + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo"), "--git-dir /repo/.git → checkout = /repo/.git.Parent = /repo"); } @@ -320,7 +327,7 @@ public void MockFsWithoutGit_DoesNotThrow_CheckoutFallsBackToSource() var opts = new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }; var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo/docs"), opts, fs); - paths.CheckoutDirectory.FullName.Should().Be("/repo/docs", + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo/docs"), "mock FS fallback: no .git → checkout = source directory"); paths.GitDirectories.Should().BeEmpty(); } @@ -351,7 +358,7 @@ public void Output_DefaultsToCheckoutArtifacts() var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); // Default output is checkout/.artifacts/docs/html — NOT the invocation path. - paths.OutputDirectory.FullName.Should().StartWith("/repo/.artifacts"); + paths.OutputDirectory.FullName.Should().StartWith(P(fs, "/repo/.artifacts")); } [Fact] @@ -381,7 +388,7 @@ public void Output_ExplicitOverride_IsRespected() var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); - paths.OutputDirectory.FullName.Should().Be("/custom/output"); + paths.OutputDirectory.FullName.Should().Be(P(fs, "/custom/output")); } // ----------------------------------------------------------------------- @@ -398,8 +405,8 @@ public void PreDiscoveredConfigFile_SkipsDocsetScan() var opts = new DocumentationScopeOptions { Inner = fs, ConfigurationFile = docsetFile }; var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project"), opts, fs); - paths.SourceDirectory.FullName.Should().Be("/project/docs"); - paths.ConfigurationPath.FullName.Should().Be("/project/docs/docset.yml"); + paths.SourceDirectory.FullName.Should().Be(P(fs, "/project/docs")); + paths.ConfigurationPath.FullName.Should().Be(P(fs, "/project/docs/docset.yml")); } // ----------------------------------------------------------------------- @@ -433,8 +440,8 @@ public void DocumentationFileSystem_Resolve_RegularRepo_ExposesResolvedPaths() var docFs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }); - docFs.Paths.CheckoutDirectory.FullName.Should().Be("/repo"); - docFs.Paths.SourceDirectory.FullName.Should().Be("/repo/docs"); + docFs.Paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo")); + docFs.Paths.SourceDirectory.FullName.Should().Be(P(fs, "/repo/docs")); docFs.Paths.Git.Branch.Should().Be("feature"); docFs.Paths.Git.RepositoryName.Should().Be("docs-builder"); } @@ -447,7 +454,7 @@ public void DocumentationFileSystem_Resolve_Worktree_ExposesMainGitInfo() var docFs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }); - docFs.Paths.CheckoutDirectory.FullName.Should().Be("/worktree"); + docFs.Paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/worktree")); docFs.Paths.Git.Branch.Should().Be("topic"); docFs.Paths.Git.Ref.Should().Be("99aabb"); docFs.Paths.Git.RepositoryName.Should().Be("worktree-test"); diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs index 4bbfb5d7d8..b65dafe949 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs @@ -97,9 +97,14 @@ public void RegularRepo_DetachedHead_NeverReturnsRandomGuid() var result = GitCheckoutInformationFactory.Create(checkout, scoped); + // The production code reads GITHUB_PR_REF_NAME ?? GITHUB_REF_NAME ?? "detached/head". + // Mirror that lookup so the test passes both locally and on CI (where GITHUB_REF_NAME=3789/merge). + var expectedBranch = Environment.GetEnvironmentVariable("GITHUB_PR_REF_NAME") + ?? Environment.GetEnvironmentVariable("GITHUB_REF_NAME") + ?? "detached/head"; result.IsAvailable.Should().BeTrue(); result.Ref.Should().Be("cafebabe9876", "detached HEAD must use the actual SHA, never a random GUID"); - result.Branch.Should().BeOneOf("detached/head"); + result.Branch.Should().Be(expectedBranch); } [Fact] From 42f39b3ebd7800fbf8010d22bb2ec6faa0b11abd Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 10 Aug 2026 18:25:55 +0200 Subject: [PATCH 13/29] Fix import ordering in ApiExplorer test files Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi --- tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs | 2 +- tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs | 2 +- .../OpenApiGeneratorCurrentSpecResolutionTests.cs | 2 +- .../OpenApiGeneratorMultiVersionTests.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs index 65e75ac074..ff36445e81 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs @@ -11,9 +11,9 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Site.FileProviders; using Microsoft.OpenApi; -using Elastic.Documentation.FileSystems; namespace Elastic.ApiExplorer.Tests; diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs index ebd75b91c8..1845cbba74 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs @@ -13,9 +13,9 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Site; using Elastic.Documentation.Site.FileProviders; -using Elastic.Documentation.FileSystems; using RazorSlices; namespace Elastic.ApiExplorer.Tests; diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs index e8addca624..6bad9da9f8 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs @@ -13,10 +13,10 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using FakeItEasy; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.OpenApi; -using Elastic.Documentation.FileSystems; namespace Elastic.ApiExplorer.Tests; diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs index 974903f63d..faba5eb973 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs @@ -17,10 +17,10 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using FakeItEasy; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.OpenApi; -using Elastic.Documentation.FileSystems; namespace Elastic.ApiExplorer.Tests; From 9d397f9db4327a408fa2bd2c42035372b4ac35b2 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 10:17:20 +0200 Subject: [PATCH 14/29] Consolidate IsSubPath helpers; add Resolve(string) overload; validate --git-dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fast-reject to IsSubPathOf(IDirectoryInfo, IDirectoryInfo): bail before walking the parent chain when directory.FullName is shorter than parent.FullName. Add IsSubPath(string, string, IFileSystem) to IDirectoryInfoExtensions — creates IDirectoryInfo via the supplied fs and delegates to IsSubPathOf. Remove four duplicated private IsSubPath(string, string) helpers from DocumentationFileSystem, DocumentationWriteFileSystem, CheckoutsFileSystem, and DocumentationPathsResolver. DocumentationWriteFileSystem.BuildOptions: change signature from (string, string?, IFileSystem?) to (IDirectoryInfo, IDirectoryInfo?, IFileSystem?) so the method has both path strings and a filesystem for IsSubPath calls without a new FileSystem(). DocumentationPathsResolver.ResolveCheckout: validate that explicit --git-dir exists and contains a HEAD file; emit a clear error otherwise. Add DocumentationFileSystem.Resolve(string, DocumentationScopeOptions?) so callers do not need a discard FileSystem instance to construct an IDirectoryInfo. Update all affected test call sites. Remove FileSystemFactory.InMemory(), InMemoryForPath(), and ScopeSourceDirectoryForWrite() — superseded by the named filesystem types. Co-Authored-By: Claude Sonnet 4.6 (1M context) Claude-Session: https://claude.ai/code/session_01PC5GiyZKhreYSqf2grz7pi --- .../BuildContext.cs | 2 +- .../DocumentationPathsResolver.cs | 22 ++++++----- .../FileSystemFactory.cs | 37 ------------------- .../FileSystems/CheckoutsFileSystem.cs | 19 ++++------ .../FileSystems/DocumentationFileSystem.cs | 30 ++++++++------- .../Extensions/IFileInfoExtensions.cs | 9 +++++ .../DocumentationWriteFileSystem.cs | 35 +++++++----------- .../ApiMarkdownIntraApiLinkTests.cs | 2 +- .../ApiPagesNavRenderingTests.cs | 2 +- .../DashboardOpenApiNavigationTests.cs | 2 +- .../KibanaApiMarkdownNavigationTests.cs | 2 +- .../Elastic.ApiExplorer.Tests/ReaderTests.cs | 2 +- .../TagMetadataTests.cs | 2 +- 13 files changed, 66 insertions(+), 100 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 864bab185a..86c7e00317 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -73,7 +73,7 @@ public string? UrlPathPrefix /// /// Primary constructor. Pass a resolved from - /// . + /// . /// public BuildContext( IDiagnosticsCollector collector, diff --git a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs index 61d3e5e600..0758d03bc4 100644 --- a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs +++ b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Extensions; using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; @@ -218,9 +219,17 @@ private static IDirectoryInfo ResolveCheckout( IFileSystem inner) { if (options.GitDir is { } explicitGitDir) + { + if (!inner.Directory.Exists(explicitGitDir.FullName)) + throw new DocumentationPathException( + $"--git-dir '{explicitGitDir.FullName}' does not exist."); + if (!inner.File.Exists(inner.Path.Join(explicitGitDir.FullName, "HEAD"))) + throw new DocumentationPathException( + $"--git-dir '{explicitGitDir.FullName}' does not appear to be a valid .git directory (no HEAD file found)."); return explicitGitDir.Parent ?? throw new DocumentationPathException( $"--git-dir '{explicitGitDir.FullName}' has no parent directory."); + } var gitRoot = Paths.FindGitRoot(gitScope.DirectoryInfo.New(source.FullName), options.MaxParents); if (gitRoot is not null) @@ -258,6 +267,7 @@ private static IReadOnlyList FilterExtraRoots( if (extraRoots is null) return []; + var fs = checkout.FileSystem; var checkoutPath = checkout.FullName; var result = new List(); foreach (var root in extraRoots) @@ -265,8 +275,8 @@ private static IReadOnlyList FilterExtraRoots( if (string.IsNullOrEmpty(root)) continue; // Drop descendants of checkout (already in scope) and ancestors (would subsume checkout). - if (!IsSubPath(root, checkoutPath) - && !IsSubPath(checkoutPath, root) + if (!IDirectoryInfoExtensions.IsSubPath(root, checkoutPath, fs) + && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, root, fs) && !result.Contains(root, StringComparer.OrdinalIgnoreCase)) { result.Add(root); @@ -274,12 +284,4 @@ private static IReadOnlyList FilterExtraRoots( } return result; } - - private static bool IsSubPath(string path, string parent) - { - var sep = Path.DirectorySeparatorChar; - var normalised = path.TrimEnd(sep) + sep; - var parentNormalised = parent.TrimEnd(sep) + sep; - return normalised.StartsWith(parentNormalised, StringComparison.OrdinalIgnoreCase); - } } diff --git a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs index 741c5eddb8..cf007505fb 100644 --- a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs +++ b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs @@ -65,35 +65,6 @@ public static class FileSystemFactory /// public static ScopedFileSystem AppData { get; } = new(new FileSystem(), AppDataOptions); - /// - /// Creates a new wrapping a fresh , - /// using the working-directory read options. Each call returns a new independent in-memory file system. - /// - public static ScopedFileSystem InMemory() => new(new MockFileSystem(), WorkingDirectoryReadOptions); - - /// - /// Like but additionally scopes the mock filesystem to 's - /// git root. Use when serving docs from a directory outside the current working tree so that the - /// in-memory output path (<source>/.artifacts/docs/html) passes scope validation. - /// - public static ScopedFileSystem InMemoryForPath(string? path) - { - if (path is null) - return InMemory(); - var plain = new FileSystem(); - var startDir = plain.DirectoryInfo.New( - plain.Directory.Exists(path) ? path : plain.Path.GetDirectoryName(path) ?? path); - var gitRoot = Paths.FindGitRoot(startDir)?.FullName; - if (gitRoot is null || gitRoot == Paths.WorkingDirectoryRoot.FullName) - return InMemory(); - return new(new MockFileSystem(), new ScopedFileSystemOptions( - [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName, gitRoot]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } - }); - } - /// /// Scopes to and /// for reading. Use when the inner FS contains files @@ -193,14 +164,6 @@ public static ScopedFileSystem ScopeSourceDirectory(IFileSystem inner, string so AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } }); - /// - /// Scopes to an explicit and - /// for writing (.git not allowed). Write variant - /// of . - /// - public static ScopedFileSystem ScopeSourceDirectoryForWrite(IFileSystem inner, string sourceRoot) => - new(inner, BuildWriteOptions(inner, sourceRoot, Paths.ApplicationData.FullName)); - /// /// Creates a read scoped to the git root of /// . Falls back to when diff --git a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs index 632bc7adfa..ad33abfa14 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Extensions; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.FileSystems; @@ -46,6 +47,7 @@ private static ScopedFileSystemOptions BuildReadOptions( IDirectoryInfo root, IEnumerable? extraRoots) { + var fs = root.FileSystem; var rootPath = root.FullName; var roots = new List { rootPath }; @@ -53,8 +55,11 @@ private static ScopedFileSystemOptions BuildReadOptions( // (/home/runner/.local/share/elastic/docs-builder/checkouts/...), so AppData would subsume // root and the ScopedFileSystem constructor would throw. var appData = Paths.ApplicationData.FullName; - if (!IsSubPath(appData, rootPath) && !IsSubPath(rootPath, appData)) + if (!IDirectoryInfoExtensions.IsSubPath(appData, rootPath, fs) + && !IDirectoryInfoExtensions.IsSubPath(rootPath, appData, fs)) + { roots.Add(appData); + } if (extraRoots is not null) { @@ -63,8 +68,8 @@ private static ScopedFileSystemOptions BuildReadOptions( if (string.IsNullOrEmpty(extra)) continue; // Drop descendants of root (already covered) and ancestors (would subsume root, causing overlap). - if (!IsSubPath(extra, rootPath) - && !IsSubPath(rootPath, extra) + if (!IDirectoryInfoExtensions.IsSubPath(extra, rootPath, fs) + && !IDirectoryInfoExtensions.IsSubPath(rootPath, extra, fs) && !roots.Contains(extra, StringComparer.OrdinalIgnoreCase)) { roots.Add(extra); @@ -88,12 +93,4 @@ public static CheckoutsFileSystem FromWorkingDirectory(IFileSystem? inner = null new( (inner ?? Physical).DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: inner); - - private static bool IsSubPath(string path, string parent) - { - var sep = System.IO.Path.DirectorySeparatorChar; - var normalised = path.TrimEnd(sep) + sep; - var parentNormalised = parent.TrimEnd(sep) + sep; - return normalised.StartsWith(parentNormalised, StringComparison.OrdinalIgnoreCase); - } } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs index f43168b2c7..c169eb2c5e 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Extensions; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.FileSystems; @@ -13,7 +14,7 @@ namespace Elastic.Documentation.FileSystems; /// checkout root. Exposes a matching scope derived from the same paths — so read and /// write cannot disagree about the checkout. /// -/// Construction is via only. The constructor is private; it takes +/// Construction is via only. The constructor is private; it takes /// already-resolved paths so that can run its bootstrap /// scopes before the final scope is built. /// @@ -71,8 +72,16 @@ public static DocumentationFileSystem Resolve( return new DocumentationFileSystem(paths, inner, opts.InnerWrite); } + public static DocumentationFileSystem Resolve(string path, DocumentationScopeOptions? options = null) + { + var opts = options ?? new DocumentationScopeOptions(); + var inner = opts.Inner ?? Physical; + return Resolve(inner.DirectoryInfo.New(path), opts); + } + private static ScopedFileSystemOptions BuildReadOptions(ResolvedDocumentationPaths paths) { + var fs = paths.CheckoutDirectory.FileSystem; var checkoutPath = paths.CheckoutDirectory.FullName; var roots = new List { checkoutPath }; @@ -80,19 +89,22 @@ private static ScopedFileSystemOptions BuildReadOptions(ResolvedDocumentationPat // (/home/runner/.local/share/elastic/docs-builder/checkouts/current/), so AppData would // subsume checkoutPath and trigger ValidateRootsAreDisjoint. var appData = Configuration.Paths.ApplicationData.FullName; - if (!IsSubPath(appData, checkoutPath) && !IsSubPath(checkoutPath, appData)) + if (!IDirectoryInfoExtensions.IsSubPath(appData, checkoutPath, fs) + && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, appData, fs)) + { roots.Add(appData); + } foreach (var gitDir in paths.GitDirectories) { - if (!IsSubPath(gitDir, checkoutPath)) + if (!IDirectoryInfoExtensions.IsSubPath(gitDir, checkoutPath, fs)) roots.Add(gitDir); } foreach (var extra in paths.ExtraRoots) { if (!string.IsNullOrEmpty(extra) - && !IsSubPath(extra, checkoutPath) + && !IDirectoryInfoExtensions.IsSubPath(extra, checkoutPath, fs) && !roots.Contains(extra, StringComparer.OrdinalIgnoreCase)) { roots.Add(extra); @@ -105,14 +117,4 @@ private static ScopedFileSystemOptions BuildReadOptions(ResolvedDocumentationPat AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } }; } - - /// Returns true if is a subdirectory of - /// (or equals it), using a case-insensitive separator-normalised comparison. - private static bool IsSubPath(string path, string parent) - { - var sep = System.IO.Path.DirectorySeparatorChar; - var normalised = path.TrimEnd(sep) + sep; - var parentNormalised = parent.TrimEnd(sep) + sep; - return normalised.StartsWith(parentNormalised, StringComparison.OrdinalIgnoreCase); - } } diff --git a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs index c3e1af48f7..8985ff2a23 100644 --- a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs +++ b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs @@ -101,6 +101,10 @@ public static bool IsCaseSensitiveFileSystem public static bool IsSubPathOf(this IDirectoryInfo directory, IDirectoryInfo parentDirectory) { var cmp = IsCaseSensitiveFileSystem ? Ordinal : OrdinalIgnoreCase; + // Fast reject: if directory's full path is shorter than parentDirectory's it can't be a + // descendant (+1 accounts for a trailing separator on one side but not the other). + if (directory.FullName.Length + 1 < parentDirectory.FullName.Length) + return false; var parent = directory; do { @@ -112,6 +116,11 @@ public static bool IsSubPathOf(this IDirectoryInfo directory, IDirectoryInfo par return false; } + /// Constructs for both paths from + /// and delegates to . + public static bool IsSubPath(string path, string parent, IFileSystem fs) => + fs.DirectoryInfo.New(path).IsSubPathOf(fs.DirectoryInfo.New(parent)); + /// Checks if has parent directory , defaults to OrdinalIgnoreCase comparison public static bool HasParent(this IDirectoryInfo directory, string parentName, StringComparison comparison = OrdinalIgnoreCase) { diff --git a/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs index 2760bebc84..cdd3dce4cb 100644 --- a/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs +++ b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.Extensions; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.FileSystems; @@ -34,7 +35,7 @@ public class DocumentationWriteFileSystem( IDirectoryInfo checkout, IDirectoryInfo? output = null, IFileSystem? inner = null) - : ScopedFileSystem(inner ?? new FileSystem(), BuildOptions(checkout.FullName, output?.FullName, inner)) + : ScopedFileSystem(inner ?? new FileSystem(), BuildOptions(checkout, output, inner)) { /// @@ -54,28 +55,27 @@ private static string ApplicationDataPath } private static ScopedFileSystemOptions BuildOptions( - string checkoutPath, - string? outputPath, + IDirectoryInfo checkout, + IDirectoryInfo? output, IFileSystem? inner) { + var fs = inner ?? checkout.FileSystem; + var checkoutPath = checkout.FullName; var roots = new List { checkoutPath }; // AppData is disjointness-filtered: on CI each docset checkout lives inside AppData // (/home/runner/.local/share/elastic/docs-builder/checkouts/current/), so AppData would // subsume checkoutPath and trigger ValidateRootsAreDisjoint. var appData = ApplicationDataPath; - if (!IsSubPath(appData, checkoutPath) && !IsSubPath(checkoutPath, appData)) - roots.Add(appData); - - if (outputPath is not null) + if (!IDirectoryInfoExtensions.IsSubPath(appData, checkoutPath, fs) + && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, appData, fs)) { - var sep = System.IO.Path.DirectorySeparatorChar; - var outputNorm = outputPath.TrimEnd(sep) + sep; - var checkoutNorm = checkoutPath.TrimEnd(sep) + sep; - if (!outputNorm.StartsWith(checkoutNorm, StringComparison.OrdinalIgnoreCase)) - roots.Add(outputPath); + roots.Add(appData); } + if (output is not null && !IDirectoryInfoExtensions.IsSubPath(output.FullName, checkout.FullName, fs)) + roots.Add(output.FullName); + // On non-Windows, MockFileSystem hardcodes a Unix-ified path ("/temp/", derived from "C:\temp") // instead of calling System.IO.Path.GetTempPath(). AllowedSpecialFolder.Temp uses the real // GetTempPath() (e.g. "/tmp/" on Linux), so the two diverge and scope validation fails for any @@ -83,11 +83,10 @@ private static ScopedFileSystemOptions BuildOptions( // // Fix tracked upstream: https://github.com/TestableIO/System.IO.Abstractions/pull/1454 // Once that ships and we update the package reference we can drop this workaround. - var innerResolved = inner ?? new FileSystem(); - var innerType = innerResolved is ScopedFileSystem sf ? sf.InnerType : innerResolved.GetType(); + var innerType = fs is ScopedFileSystem sf ? sf.InnerType : fs.GetType(); if (!OperatingSystem.IsWindows() && innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) { - var innerTemp = innerResolved.Path.GetTempPath().TrimEnd( + var innerTemp = fs.Path.GetTempPath().TrimEnd( System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); if (!string.IsNullOrEmpty(innerTemp) && !roots.Contains(innerTemp, StringComparer.OrdinalIgnoreCase)) roots.Add(innerTemp); @@ -100,10 +99,4 @@ private static ScopedFileSystemOptions BuildOptions( AllowedSpecialFolders = AllowedSpecialFolder.Temp }; } - - private static bool IsSubPath(string path, string parent) - { - var sep = System.IO.Path.DirectorySeparatorChar; - return (path.TrimEnd(sep) + sep).StartsWith(parent.TrimEnd(sep) + sep, StringComparison.OrdinalIgnoreCase); - } } diff --git a/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs index ff36445e81..a85fb3ade7 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs @@ -37,7 +37,7 @@ public void Render_RewritesGroupAndOperationLinksAgainstCurrentApiBase() var renderer = new CapturingRenderer(); var collector = new DiagnosticsCollector([]); var fs = new FileSystem(); - var context = new BuildContext(collector, DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), TestHelpers.CreateConfigurationContext(fs)); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), TestHelpers.CreateConfigurationContext(fs)); var renderContext = new ApiRenderContext(context, new OpenApiDocument(), new StaticFileContentHashProvider(new EmbeddedOrPhysicalFileProvider(context))) { NavigationHtml = string.Empty, diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs index 1845cbba74..e10b977137 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs @@ -28,7 +28,7 @@ public async Task Render_MarksOnlyCurrentVersionSelected() var fs = new FileSystem(); var context = new BuildContext( new DiagnosticsCollector([]), - DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), TestHelpers.CreateConfigurationContext(fs)); var navigationItem = new LandingNavigationItem("/api/doc/elasticsearch/v9/").Index; var model = new ApiLayoutViewModel diff --git a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs index d97d3e2301..2a11d229a1 100644 --- a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs @@ -25,7 +25,7 @@ public class DashboardOpenApiNavigationTests public async Task CreateNavigation_SingleTagOpenApiSpec_HasSidebarItems() { var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(new DiagnosticsCollector([]), DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), configurationContext); + var context = new BuildContext(new DiagnosticsCollector([]), DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); var fs = new FileSystem(); var path = fs.Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "dashboard-openapi.json"); var fi = fs.FileInfo.New(path); diff --git a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs index 18f5768ede..8fbbce8e50 100644 --- a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs @@ -54,7 +54,7 @@ private static (LandingNavigationItem navigation, SimpleMarkdownNavigationItem i var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var context = new BuildContext(collector, DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), configurationContext); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); var doc = OpenApiReader.Instance.ReadAsync(specFile).GetAwaiter().GetResult(); doc.Should().NotBeNull("OpenAPI document should load successfully"); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); diff --git a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs index 4a05de71b7..88672a7060 100644 --- a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs @@ -52,7 +52,7 @@ public async Task Navigation() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), configurationContext); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); var openApiDocument = await OpenApiReader.Instance.ReadAsync(LocalSpecFile()); diff --git a/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs b/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs index a7c0585afb..a8016491c6 100644 --- a/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs @@ -285,7 +285,7 @@ public async Task ApiTag_StableNavigationIds_UsesCanonicalTagName() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName)), configurationContext); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); From 4ffc63f4497c7aeb3495021e722deddb5f7bddb7 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 11:42:11 +0200 Subject: [PATCH 15/29] Delete Scope* factory methods; migrate all test sites to named types Remove ScopeCurrentWorkingDirectory (both overloads), ScopeCurrentWorkingDirectoryForWrite, and ScopeSourceDirectory from FileSystemFactory. All ~90 test sites and the two DocumentationSetFile shims now use CheckoutsFileSystem directly. Migration patterns: - Assembler/integration/changelog tests: CheckoutsFileSystem.FromWorkingDirectory(inner) - Navigation assembler tests: new CheckoutsFileSystem(fs.DirectoryInfo.New("/checkouts"), inner: fs) - ScopeCurrentWorkingDirectoryForWrite: CheckoutsFileSystem.FromWorkingDirectory(inner).Write - ScopeSourceDirectory(fs, root): new CheckoutsFileSystem(fs.DirectoryInfo.New(root), inner: fs) - FileSystemFactoryTests: three Scope* tests renamed, use CheckoutsFileSystem ctor with extraRoots RealReadForRunnerTemp no longer delegates to the deleted method; it constructs CheckoutsFileSystem directly with the extra RUNNER_TEMP root. The two ??= shims in DocumentationSetFile.LoadAndResolve switch from FileSystemFactory.ScopeSourceDirectory to new CheckoutsFileSystem(dir, inner: fs); commit 5 narrows the parameter to DocumentationFileSystem and removes the ??=. Also: AssembleContext, CodexContext, and three assembler service methods narrow their readFs param from ScopedFileSystem to CheckoutsFileSystem; integration test hand-rolled scope+write pairs become CheckoutsFileSystem.FromWorkingDirectory(fs) + fs.Write; F# authoring assertion migrated alongside the C# test doubles. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Elastic.Codex/CodexContext.cs | 5 +- .../Toc/DocumentationSetFile.cs | 5 +- .../FileSystemFactory.cs | 71 +------------------ .../FileSystems/GitResolveFileSystem.cs | 12 +--- .../AssembleContext.cs | 2 +- .../Building/AssemblerBuildService.cs | 3 +- .../Indexing/AssemblerAiEnrichService.cs | 3 +- .../Indexing/AssemblerIndexService.cs | 3 +- .../AssemblerConfigurationTests.cs | 11 ++- .../DocsSyncTests.cs | 16 ++--- .../IncrementalDeployRoundTripTests.cs | 16 ++--- .../NavigationBuildingTests.cs | 6 +- .../NavigationRootTests.cs | 6 +- .../SiteNavigationTests.cs | 16 ++--- .../Changelogs/BundleChangelogsTests.cs | 7 +- .../Changelogs/ChangelogRemoveTests.cs | 3 +- .../Changelogs/ChangelogTestBase.cs | 3 +- .../Creation/ChangelogCreationServiceTests.cs | 3 +- .../Evaluation/ChangelogPrBodyReaderTests.cs | 5 +- .../Uploading/ChangelogUploadServiceTests.cs | 6 +- .../Uploading/RegistryBuilderTests.cs | 6 +- .../FileSystemFactoryTests.cs | 18 ++--- .../GitCheckoutResolutionTests.cs | 11 +-- .../Directives/CsvIncludeTests.cs | 9 +-- .../Assembler/ComplexSiteNavigationTests.cs | 11 +-- .../Assembler/IdentifierCollectionTests.cs | 13 ++-- .../Assembler/SiteDocumentationSetsTests.cs | 41 +++++------ .../Assembler/SiteNavigationTests.cs | 19 ++--- .../Codex/CodexConfigurationLoaderTests.cs | 6 +- .../Codex/CodexNavigationTestBase.cs | 2 +- .../Codex/FindDocsetFileTests.cs | 6 +- .../Codex/GroupNavigationTests.cs | 2 +- .../TestDocumentationSetContext.cs | 2 +- .../Framework/CrossLinkResolverAssertions.fs | 2 +- 34 files changed, 136 insertions(+), 214 deletions(-) diff --git a/src/Elastic.Codex/CodexContext.cs b/src/Elastic.Codex/CodexContext.cs index bf6f829533..503055c787 100644 --- a/src/Elastic.Codex/CodexContext.cs +++ b/src/Elastic.Codex/CodexContext.cs @@ -38,10 +38,11 @@ public CodexContext( CodexConfiguration configuration, IFileInfo configurationPath, IDiagnosticsCollector collector, - ScopedFileSystem readFileSystem, + CheckoutsFileSystem readFileSystem, DocumentationWriteFileSystem writeFileSystem, string? checkoutDirectory, - string? outputDirectory) + string? outputDirectory + ) { Configuration = configuration; ConfigurationPath = configurationPath; diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index 5ea875eb28..9c561c809f 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration.Toc.DetectionRules; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; using YamlDotNet.Serialization; using static Elastic.Documentation.SymlinkValidator; @@ -126,7 +127,7 @@ public static DocumentationSetFile LoadMetadata(IFileInfo file) /// public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, IFileInfo docsetPath, ScopedFileSystem? fileSystem = null, HashSet? noSuppress = null) { - fileSystem ??= FileSystemFactory.ScopeSourceDirectory(docsetPath.FileSystem, docsetPath.Directory!.FullName); + fileSystem ??= new CheckoutsFileSystem(docsetPath.Directory!, inner: docsetPath.FileSystem); // Validate that the docset.yml is not a symlink (security: prevents path traversal attacks) EnsureNotSymlink(docsetPath); var yaml = fileSystem.File.ReadAllText(docsetPath.FullName); @@ -148,7 +149,7 @@ public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collecto /// public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, string yaml, IDirectoryInfo sourceDirectory, ScopedFileSystem? fileSystem = null, HashSet? noSuppress = null) { - fileSystem ??= FileSystemFactory.ScopeSourceDirectory(sourceDirectory.FileSystem, sourceDirectory.FullName); + fileSystem ??= new CheckoutsFileSystem(sourceDirectory, inner: sourceDirectory.FileSystem); var docSet = Deserialize(yaml); var docsetPath = fileSystem.Path.Join(sourceDirectory.FullName, "docset.yml").OptionalWindowsReplace(); docSet.SuppressDiagnostics.ExceptWith(noSuppress ?? []); diff --git a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs index cf007505fb..55677f6434 100644 --- a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs +++ b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; using DirectoryInfoExtensions = Elastic.Documentation.Extensions.IDirectoryInfoExtensions; @@ -65,51 +66,6 @@ public static class FileSystemFactory /// public static ScopedFileSystem AppData { get; } = new(new FileSystem(), AppDataOptions); - /// - /// Scopes to and - /// for reading. Use when the inner FS contains files - /// that live within the current working-directory tree (e.g. a test MockFileSystem - /// seeded with workspace-relative paths). - /// - public static ScopedFileSystem ScopeCurrentWorkingDirectory(IFileSystem inner) => - new(inner, WorkingDirectoryReadOptions); - - /// - /// Scopes to and - /// for reading, extended by - /// (e.g. detection-rules folders declared via - /// ). - /// - public static ScopedFileSystem ScopeCurrentWorkingDirectory(IFileSystem inner, IEnumerable? extensionRoots) - { - if (extensionRoots is null) - return ScopeCurrentWorkingDirectory(inner); - - var workingRoot = inner.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); - var externalRoots = extensionRoots - .Select(r => inner.DirectoryInfo.New(r)) - // Drop descendants of the working root (already covered by the base scope). - // Also drop ancestors of the working root — they would subsume the working root, - // producing overlapping roots that ScopedFileSystem rejects with ArgumentException. - .Where(d => !DirectoryInfoExtensions.IsSubPathOf(d, workingRoot) && !DirectoryInfoExtensions.IsSubPathOf(workingRoot, d)) - .Select(d => d.FullName) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (externalRoots.Length == 0) - return ScopeCurrentWorkingDirectory(inner); - - var roots = new[] { Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName } - .Concat(externalRoots) - .ToArray(); - - return new ScopedFileSystem(inner, new ScopedFileSystemOptions(roots) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } - }); - } - // Builds write options that include AllowedSpecialFolders.Temp PLUS the inner FS's own // GetTempPath() as an explicit root — but only when the inner FS is MockFileSystem. // @@ -142,28 +98,6 @@ private static ScopedFileSystemOptions BuildWriteOptions(IFileSystem inner, para }; } - /// - /// Scopes to and - /// for writing (.git not allowed). Use when - /// the inner FS writes into the working-directory tree. - /// - public static ScopedFileSystem ScopeCurrentWorkingDirectoryForWrite(IFileSystem inner) => - new(inner, BuildWriteOptions( - inner, Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName)); - - /// - /// Scopes to an explicit and - /// for reading. Use when the files to be read live under - /// a specific known root that is not — for example - /// test fixtures with assembler-checkout paths or service code operating on a given directory. - /// - public static ScopedFileSystem ScopeSourceDirectory(IFileSystem inner, string sourceRoot) => - new(inner, new ScopedFileSystemOptions([sourceRoot, Paths.ApplicationData.FullName]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } - }); - /// /// Creates a read scoped to the git root of /// . Falls back to when @@ -256,6 +190,7 @@ public static ScopedFileSystem RealReadForRunnerTemp(IEnvironmentVariables? envi if (string.IsNullOrWhiteSpace(runnerTemp)) return RealRead; - return ScopeCurrentWorkingDirectory(new FileSystem(), [runnerTemp]); + var plain = new FileSystem(); + return new CheckoutsFileSystem(plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), extraRoots: [runnerTemp]); } } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs index a94c093234..849319bd96 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.Extensions; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.FileSystems; @@ -47,11 +48,12 @@ private static ScopedFileSystemOptions BuildOptions( var rootPath = root.FullName; var roots = new List { rootPath }; + var fs = anchor.FileSystem; if (gitDirectories is { Count: > 0 }) { foreach (var gitDir in gitDirectories) { - if (!IsSubPath(gitDir, rootPath)) + if (!IDirectoryInfoExtensions.IsSubPath(gitDir, rootPath, fs)) roots.Add(gitDir); } } @@ -62,12 +64,4 @@ private static ScopedFileSystemOptions BuildOptions( AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } }; } - - private static bool IsSubPath(string path, string parent) - { - var sep = System.IO.Path.DirectorySeparatorChar; - var normalised = path.TrimEnd(sep) + sep; - var parentNormalised = parent.TrimEnd(sep) + sep; - return normalised.StartsWith(parentNormalised, StringComparison.OrdinalIgnoreCase); - } } diff --git a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs index 405764b40b..dc0c4bbca1 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs @@ -67,7 +67,7 @@ public AssembleContext( IConfigurationContext configurationContext, string environment, IDiagnosticsCollector collector, - ScopedFileSystem readFileSystem, + CheckoutsFileSystem readFileSystem, DocumentationWriteFileSystem writeFileSystem, string? checkoutDirectory, string? output diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 5a164eec79..921efd958a 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -17,7 +17,6 @@ using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Building; @@ -35,7 +34,7 @@ IEnvironmentVariables environmentVariables public async Task BuildAll( IDiagnosticsCollector collector, AssemblerBuildOptions options, - ScopedFileSystem readFs, + CheckoutsFileSystem readFs, DocumentationWriteFileSystem writeFs, Cancel ctx ) diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs index 9b9b9621e1..abe3b76c0c 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs @@ -12,7 +12,6 @@ using Elastic.Ingest.Elasticsearch.Enrichment; using Elastic.Markdown.Exporters.Elasticsearch; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Indexing; @@ -35,7 +34,7 @@ ICoreService githubActionsService /// public async Task AiEnrich( IDiagnosticsCollector collector, - ScopedFileSystem readFs, + CheckoutsFileSystem readFs, DocumentationWriteFileSystem writeFs, ElasticsearchIndexOptions es, string? environment, diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs index 910cf2e0f0..ff39788905 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs @@ -10,7 +10,6 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; using static Elastic.Documentation.Exporter; namespace Elastic.Documentation.Assembler.Indexing; @@ -28,7 +27,7 @@ IEnvironmentVariables environmentVariables /// Index assembled documentation to Elasticsearch. public async Task Index( IDiagnosticsCollector collector, - ScopedFileSystem readFs, + CheckoutsFileSystem readFs, DocumentationWriteFileSystem writeFs, ElasticsearchIndexOptions es, string? environment = null, diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 295054c6c9..49aa7dc274 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -10,7 +10,6 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.IntegrationTests; @@ -30,9 +29,8 @@ public PublicOnlyAssemblerConfigurationTests() var configurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, FileSystem, skipPrivateRepositories: true); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem, configurationFileProvider: configurationFileProvider); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); - var writeFs = new DocumentationWriteFileSystem(FileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, writeFs, CheckoutDirectory.FullName, null); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs, assembleFs.Write, CheckoutDirectory.FullName, null); } [Fact] @@ -68,9 +66,8 @@ public AssemblerConfigurationTests(DocumentationFixture fixture, ITestOutputHelp Collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); - var writeFs = new DocumentationWriteFileSystem(FileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, writeFs, CheckoutDirectory.FullName, null); + var assembleFs2 = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs2, assembleFs2.Write, CheckoutDirectory.FullName, null); } [Fact] diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs index 56e9e5527f..92622bcef9 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs @@ -18,7 +18,6 @@ using Elastic.Documentation.ServiceDefaults.Telemetry; using FakeItEasy; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; using OpenTelemetry; using OpenTelemetry.Trace; @@ -47,9 +46,8 @@ public async Task TestPlan() var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var scopedWriteFs = new DocumentationWriteFileSystem(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs, scopedWriteFs, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs, assembleFs.Write, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); A.CallTo(() => mockS3Client.ListObjectsV2Async(A._, A._)) .Returns(new ListObjectsV2Response { @@ -190,9 +188,8 @@ bool valid var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs2 = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var scopedWriteFs2 = new DocumentationWriteFileSystem(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs2, scopedWriteFs2, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); + var assembleFs2 = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs2, assembleFs2.Write, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); var s3Objects = new List(); foreach (var i in Enumerable.Range(0, remoteFiles)) @@ -242,9 +239,8 @@ public async Task TestApply() var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var checkoutDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); - var scopedFs3 = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var scopedWriteFs3 = new DocumentationWriteFileSystem(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs3, scopedWriteFs3, null, checkoutDirectory); + var assembleFs3 = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs3, assembleFs3.Write, null, checkoutDirectory); var plan = new SyncPlan { RemoteListingCompleted = true, diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs index 7978b38ecf..43e9b179fa 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs @@ -20,7 +20,6 @@ using Elastic.Documentation.Integrations.S3; using FakeItEasy; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.IntegrationTests; @@ -48,9 +47,8 @@ public async Task AssemblerRoundTrip() var configurationContext = TestHelpers.CreateConfigurationContext(fs); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new DiagnosticsCollector([]); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var scopedWriteFs = new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs, scopedWriteFs, null, outputDir); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs, assembleFs.Write, null, outputDir); await RunRoundTrip(fs, s3, xfer, gh, svc, context, outputDir); } @@ -61,13 +59,12 @@ public async Task CodexRoundTrip() var outputDir = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "codex", "docs"); var (fs, s3, xfer, gh, svc) = Arrange(outputDir); var collector = new DiagnosticsCollector([]); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var scopedWriteFs = new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs); + var codexFs = CheckoutsFileSystem.FromWorkingDirectory(fs); // CodexContext only stores configurationPath — it never reads from it — // so we can point to any path without adding it to the mock FS. var codexConfig = new CodexConfiguration { Environment = "dev" }; var configFile = fs.FileInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); - var context = new CodexContext(codexConfig, configFile, collector, scopedFs, scopedWriteFs, null, outputDir); + var context = new CodexContext(codexConfig, configFile, collector, codexFs, codexFs.Write, null, outputDir); await RunRoundTrip(fs, s3, xfer, gh, svc, context, outputDir); } @@ -228,11 +225,10 @@ public async Task ExcludedRemoteObjectsAreNotDeleted() var svc = new IncrementalDeployService(new LoggerFactory(), gh, s3, xfer, etagCalculator); var collector = new DiagnosticsCollector([]); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var scopedWriteFs = new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs); + var codexFs2 = CheckoutsFileSystem.FromWorkingDirectory(fs); var codexConfig = new CodexConfiguration { Environment = "dev" }; var configFile = fs.FileInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); - var context = new CodexContext(codexConfig, configFile, collector, scopedFs, scopedWriteFs, null, outputDir); + var context = new CodexContext(codexConfig, configFile, collector, codexFs2, codexFs2.Write, null, outputDir); var planPath = Path.Join(outputDir, "sync-plan.json"); var planOk = await svc.Plan( diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs index 08b7953b8a..fcbec4ae83 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs @@ -22,7 +22,6 @@ using Elastic.Documentation.Site.Navigation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Nullean.ScopedFileSystem; using RazorSlices; namespace Elastic.Documentation.IntegrationTests; @@ -47,10 +46,9 @@ public async Task AssertRealNavigation() var assemblyConfiguration = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); var fs = new FileSystem(); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, - FileSystemFactory.ScopeCurrentWorkingDirectory(fs), - new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs), - null, null); + assembleFs, assembleFs.Write, null, null); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs index 9e2add1c3a..87c05c7d48 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs @@ -22,7 +22,6 @@ using Elastic.Documentation.Site.Navigation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Nullean.ScopedFileSystem; using RazorSlices; namespace Elastic.Documentation.IntegrationTests; @@ -47,10 +46,9 @@ public async Task AssertRealNavigation() var assemblyConfiguration = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); var fs = new FileSystem(); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, - FileSystemFactory.ScopeCurrentWorkingDirectory(fs), - new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs), - null, null); + assembleFs, assembleFs.Write, null, null); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs index 8b4de28972..e06add9746 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs @@ -16,7 +16,6 @@ using Elastic.Documentation.Navigation.Assembler; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.IntegrationTests; @@ -45,9 +44,8 @@ public SiteNavigationTests(DocumentationFixture fixture, ITestOutputHelper outpu Collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); - var writeFs = new DocumentationWriteFileSystem(FileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, writeFs, CheckoutDirectory.FullName, null); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs, assembleFs.Write, CheckoutDirectory.FullName, null); } private Checkout CreateCheckout(IFileSystem fs, Repository repository) @@ -100,9 +98,8 @@ public async Task ReadAllPathPrefixes() var fileSystem = new FileSystem(); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var writeFileSystem = new DocumentationWriteFileSystem(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFileSystem, writeFileSystem, null, null); + var assembleFs2 = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs2, assembleFs2.Write, null, null); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; var siteNavigationFile = SiteNavigationFile.Deserialize(await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken)); @@ -194,9 +191,8 @@ public async Task UriResolving() var fs = new FileSystem(); var configurationContext = TestHelpers.CreateConfigurationContext(fs); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var assembleWriteFs = new DocumentationWriteFileSystem(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, fs); - var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, scopedFs, assembleWriteFs, null, null); + var assembleFs3 = CheckoutsFileSystem.FromWorkingDirectory(fs); + var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, assembleFs3, assembleFs3.Write, null, null); var repos = assembleContext.Configuration.AvailableRepositories .Where(kv => !kv.Value.Skip) .Select(kv => kv.Value) diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 66e03bd316..343884f9bb 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -8,6 +8,7 @@ using Elastic.Changelog.Utilities; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Changelog.Tests.Changelogs; @@ -3262,7 +3263,7 @@ public async Task BundleChangelogs_WithProfileMode_MissingConfig_ReturnsErrorWit currentDirectory: "/empty-project" ); cwdFs.Directory.CreateDirectory("/empty-project"); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, CheckoutsFileSystem.FromWorkingDirectory(cwdFs)); var input = new BundleChangelogsArguments { @@ -3323,7 +3324,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSucce """; await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, CheckoutsFileSystem.FromWorkingDirectory(cwdFs)); var input = new BundleChangelogsArguments { @@ -3384,7 +3385,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce """; await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, CheckoutsFileSystem.FromWorkingDirectory(cwdFs)); var input = new BundleChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs index 9ea8290e30..a5f98e737e 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -6,6 +6,7 @@ using Elastic.Changelog.Bundling; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Tests.Changelogs; @@ -457,7 +458,7 @@ public async Task Remove_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() currentDirectory: "/empty-project" ); cwdFs.Directory.CreateDirectory("/empty-project"); - var service = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); + var service = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, CheckoutsFileSystem.FromWorkingDirectory(cwdFs)); var input = new ChangelogRemoveArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs index 4a3254c947..6bf1db2574 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -30,7 +31,7 @@ protected ChangelogTestBase(ITestOutputHelper output) { Output = output; var mockFileSystem = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - FileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFileSystem); + FileSystem = CheckoutsFileSystem.FromWorkingDirectory(mockFileSystem); Collector = new TestDiagnosticsCollector(output); LoggerFactory = new TestLoggerFactory(output); diff --git a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs index 60480bbc4f..0cbf160621 100644 --- a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs @@ -11,6 +11,7 @@ using Elastic.Changelog.Utilities; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using FakeItEasy; namespace Elastic.Changelog.Tests.Creation; @@ -197,7 +198,7 @@ public async Task CreateChangelog_CIWithoutProducts_NoPrProductLabels_FailsWithP public async Task CreateChangelog_TempOutputDirectory_Succeeds() { var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(mockFs); + var writeFs = CheckoutsFileSystem.FromWorkingDirectory(mockFs).Write; var configPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"); writeFs.Directory.CreateDirectory(writeFs.Path.GetDirectoryName(configPath)!); diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs index 3c8c29e9c9..22501fbb35 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs @@ -7,6 +7,7 @@ using Elastic.Changelog.Evaluation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Tests.Evaluation; @@ -30,7 +31,7 @@ public async Task ReadAsync_PrBodyFileUnderWorkingDir_ReadsBody() public async Task ReadAsync_PrBodyFileMissing_EmitsWarning() { var bodyPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "missing-pr-body.md"); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(CreateMockFileSystem()); + var scopedFs = CheckoutsFileSystem.FromWorkingDirectory(CreateMockFileSystem()); var collector = new TestDiagnosticsCollector(output); var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, scopedFs, TestContext.Current.CancellationToken); @@ -48,7 +49,7 @@ public async Task ReadAsync_PrBodyFileOutsideScope_EmitsWarning() var bodyPath = Path.Join(runnerTemp, "changelog-pr-body.md"); var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); mockFs.AddFile(bodyPath, new MockFileData("Release Notes: something important")); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs); + var scopedFs = CheckoutsFileSystem.FromWorkingDirectory(mockFs); var collector = new TestDiagnosticsCollector(output); var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, scopedFs, TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs index 2217c36306..92654c4d2f 100644 --- a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs @@ -11,9 +11,9 @@ using Elastic.Changelog.Tests.Changelogs; using Elastic.Changelog.Uploading; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using FakeItEasy; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Tests.Uploading; @@ -21,7 +21,7 @@ namespace Elastic.Changelog.Tests.Uploading; public class ChangelogUploadServiceTests { private readonly MockFileSystem _mockFileSystem; - private readonly ScopedFileSystem _fileSystem; + private readonly CheckoutsFileSystem _fileSystem; private readonly IAmazonS3 _s3Client = A.Fake(); private readonly ChangelogUploadService _service; private readonly TestDiagnosticsCollector _collector; @@ -33,7 +33,7 @@ public ChangelogUploadServiceTests(ITestOutputHelper output) { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - _fileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(_mockFileSystem); + _fileSystem = CheckoutsFileSystem.FromWorkingDirectory(_mockFileSystem); _service = new ChangelogUploadService(NullLoggerFactory.Instance, fileSystem: _fileSystem, s3Client: _s3Client); _collector = new TestDiagnosticsCollector(output); _changelogDir = _mockFileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog"); diff --git a/tests/Elastic.Changelog.Tests/Uploading/RegistryBuilderTests.cs b/tests/Elastic.Changelog.Tests/Uploading/RegistryBuilderTests.cs index bf8566e0c9..5070cb311e 100644 --- a/tests/Elastic.Changelog.Tests/Uploading/RegistryBuilderTests.cs +++ b/tests/Elastic.Changelog.Tests/Uploading/RegistryBuilderTests.cs @@ -13,10 +13,10 @@ using Elastic.Changelog.Tests.Changelogs; using Elastic.Changelog.Uploading; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Integrations.S3; using FakeItEasy; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Tests.Uploading; @@ -24,7 +24,7 @@ namespace Elastic.Changelog.Tests.Uploading; public class RegistryBuilderTests { private readonly MockFileSystem _mockFileSystem; - private readonly ScopedFileSystem _fileSystem; + private readonly CheckoutsFileSystem _fileSystem; private readonly IAmazonS3 _s3Client = A.Fake(); private readonly TestDiagnosticsCollector _collector; private readonly string _bundleDir; @@ -37,7 +37,7 @@ public RegistryBuilderTests(ITestOutputHelper output) { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - _fileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(_mockFileSystem); + _fileSystem = CheckoutsFileSystem.FromWorkingDirectory(_mockFileSystem); _collector = new TestDiagnosticsCollector(output); _bundleDir = _mockFileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "releases"); _mockFileSystem.Directory.CreateDirectory(_bundleDir); diff --git a/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs index bcef22ddf7..96bafff2fb 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Configuration.Tests; @@ -18,7 +19,7 @@ sealed file class StubEnv(string? runnerTemp) : IEnvironmentVariables public class FileSystemFactoryTests { [Fact] - public void ScopeCurrentWorkingDirectory_NestedExtensionRoot_DoesNotThrow() + public void CheckoutsFileSystem_NestedExtensionRoot_DoesNotThrow() { var workingRoot = Paths.WorkingDirectoryRoot.FullName; var nestedConfigDir = Path.Join(workingRoot, "environments", "internal"); @@ -28,16 +29,17 @@ public void ScopeCurrentWorkingDirectory_NestedExtensionRoot_DoesNotThrow() { configPath, new MockFileData("environment: internal") } }); - var act = () => FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [nestedConfigDir]); + var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), extraRoots: [nestedConfigDir], inner: mockFs); act.Should().NotThrow(); - var scoped = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [nestedConfigDir]); + var scoped = act(); scoped.File.Exists(configPath).Should().BeTrue(); } [Fact] - public void ScopeCurrentWorkingDirectory_ExternalExtensionRoot_AllowsReadingExternalConfig() + public void CheckoutsFileSystem_ExternalExtensionRoot_AllowsReadingExternalConfig() { + var workingRoot = Paths.WorkingDirectoryRoot.FullName; var externalRoot = Path.Join(Path.GetTempPath(), $"external-codex-{Guid.NewGuid():N}"); var configPath = Path.Join(externalRoot, "codex.yml"); var mockFs = new MockFileSystem(new Dictionary @@ -45,13 +47,13 @@ public void ScopeCurrentWorkingDirectory_ExternalExtensionRoot_AllowsReadingExte { configPath, new MockFileData("environment: internal") } }); - var scoped = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [externalRoot]); + var scoped = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), extraRoots: [externalRoot], inner: mockFs); scoped.File.Exists(configPath).Should().BeTrue(); } [Fact] - public void ScopeCurrentWorkingDirectory_AncestorExtensionRoot_DoesNotThrow() + public void CheckoutsFileSystem_AncestorExtensionRoot_DoesNotThrow() { // An ancestor of the working root would produce overlapping roots, the same class of // crash fixed in a96ef869 / 3c5f9703 for Codex nested paths. @@ -59,7 +61,7 @@ public void ScopeCurrentWorkingDirectory_AncestorExtensionRoot_DoesNotThrow() var ancestor = Path.GetDirectoryName(workingRoot)!; var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = workingRoot }); - var act = () => FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [ancestor]); + var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), extraRoots: [ancestor], inner: mockFs); act.Should().NotThrow(); var scoped = act(); @@ -96,7 +98,7 @@ public void RealReadForRunnerTemp_RunnerTempSibling_AllowsReadingStagedFile() var scoped = FileSystemFactory.RealReadForRunnerTemp(env); // We call the overload that accepts inner FS, reusing the factory helper // that RealReadForRunnerTemp delegates to. - var scopedMock = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [tempDir]); + var scopedMock = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), extraRoots: [tempDir], inner: mockFs); scopedMock.File.Exists(stagedFile).Should().BeTrue(); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs index b65dafe949..17df8c3656 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs @@ -6,6 +6,7 @@ using AwesomeAssertions; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Configuration.Tests; @@ -75,7 +76,7 @@ public void RegularRepo_ReturnsGitInfo() var fs = BuildFs("/repo", branch: "feature/my-branch", sha: "deadbeef1234"); var checkout = fs.DirectoryInfo.New("/repo"); - var scoped = FileSystemFactory.ScopeSourceDirectory(fs, "/repo"); + var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/repo"), inner: fs); var result = GitCheckoutInformationFactory.Create(checkout, scoped); @@ -93,7 +94,7 @@ public void RegularRepo_DetachedHead_NeverReturnsRandomGuid() var fs = BuildFs("/repo", branch: null, sha: "cafebabe9876"); var checkout = fs.DirectoryInfo.New("/repo"); - var scoped = FileSystemFactory.ScopeSourceDirectory(fs, "/repo"); + var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/repo"), inner: fs); var result = GitCheckoutInformationFactory.Create(checkout, scoped); @@ -115,7 +116,7 @@ public void WorktreeWithAbsoluteGitDir_ResolvesViaMainRepo() worktree: true, worktreeGitDir: "/main-repo/.git/worktrees/my-feature"); // Scope must cover both the worktree dir and the main .git - var scoped = FileSystemFactory.ScopeSourceDirectory(fs, "/worktree"); + var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/worktree"), inner: fs); var extended = new Nullean.ScopedFileSystem.ScopedFileSystem(fs, new Nullean.ScopedFileSystem.ScopedFileSystemOptions(["/worktree", "/main-repo/.git/worktrees/my-feature"]) { @@ -208,7 +209,7 @@ public void WorktreeMissingGitDir_ReturnsUnavailable() fs.AddFile("/worktree/.git", new MockFileData("gitdir: /nonexistent/.git/worktrees/wt\n")); var checkout = fs.DirectoryInfo.New("/worktree"); - var scoped = FileSystemFactory.ScopeSourceDirectory(fs, "/worktree"); + var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/worktree"), inner: fs); var result = GitCheckoutInformationFactory.Create(checkout, scoped); @@ -222,7 +223,7 @@ public void MockWithNoGitLayout_ReturnsCannedTestData() // test instance rather than Unavailable, so existing test suites need no churn. var fs = new MockFileSystem(); var checkout = fs.DirectoryInfo.New("/some/path"); - var scoped = FileSystemFactory.ScopeSourceDirectory(fs, "/some/path"); + var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/some/path"), inner: fs); var result = GitCheckoutInformationFactory.Create(checkout, scoped); diff --git a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs index 0307f5ff3d..d705229689 100644 --- a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Markdown.Myst.Directives.CsvInclude; namespace Elastic.Markdown.Tests.Directives; @@ -38,7 +39,7 @@ public CsvIncludeTests(ITestOutputHelper output) : base(output, [Fact] public void ParsesCsvDataCorrectly() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(4); csvData[0].Should().BeEquivalentTo(["Name", "Age", "City"]); csvData[1].Should().BeEquivalentTo(["John Doe", "30", "New York"]); @@ -72,7 +73,7 @@ public CsvIncludeWithOptionsTests(ITestOutputHelper output) : base(output, [Fact] public void ParsesWithCustomSeparator() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Age", "City"]); csvData[1].Should().BeEquivalentTo(["John Doe", "30", "New York"]); @@ -94,7 +95,7 @@ public CsvIncludeWithQuotesTests(ITestOutputHelper output) : base(output, [Fact] public void HandlesQuotedFieldsWithCommas() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Description", "Location"]); csvData[1].Should().BeEquivalentTo(["John Doe", "Software Engineer, Senior", "New York"]); @@ -116,7 +117,7 @@ public CsvIncludeWithEscapedQuotesTests(ITestOutputHelper output) : base(output, [Fact] public void HandlesEscapedQuotes() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Description"]); csvData[1].Should().BeEquivalentTo(["John Doe", "He said \"Hello World\" today"]); diff --git a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs index 508b09cd2e..7772a1d24e 100644 --- a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs @@ -4,6 +4,7 @@ using AwesomeAssertions; using Elastic.Documentation.Assembler.Navigation; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation.Assembler; @@ -38,7 +39,7 @@ public async Task MultipleSectionsFromSameRepository_UseContentHashesAndCacheByR var docset = DocumentationSetFile.LoadAndResolve( context.Collector, fileSystem.FileInfo.New($"{repositoryPath}/docs/docset.yml"), - FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); documentationSets.Add( new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance)); } @@ -101,7 +102,7 @@ public void ComplexNavigationWithMultipleNestedTocsAppliesPathPrefixToRootUrls() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -179,7 +180,7 @@ public void DeeplyNestedNavigationMaintainsPathPrefixThroughoutHierarchy() var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { @@ -230,7 +231,7 @@ public void FileNavigationLeafUrlsReflectPathPrefixInDeeplyNestedStructures() var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { @@ -292,7 +293,7 @@ public void FolderNavigationWithinNestedTocsHasCorrectPathPrefix() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { diff --git a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs index 9936f9f569..5968e3043f 100644 --- a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs +++ b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs @@ -4,6 +4,7 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Node; @@ -21,7 +22,7 @@ public void DocumentationSetNavigationCollectsRootIdentifier() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); // Root identifier should be :// @@ -36,7 +37,7 @@ public void DocumentationSetNavigationCollectsNestedTocIdentifiers() // Test platform repository with nested TOCs var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, platformContext.ConfigurationPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, platformContext.ConfigurationPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); // Should collect identifiers from nested TOCs @@ -57,7 +58,7 @@ public void DocumentationSetNavigationWithSimpleStructure() // Test observability repository (no nested TOCs) var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); // Should only have root identifier @@ -74,7 +75,7 @@ public void TableOfContentsNavigationHasCorrectIdentifier() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); // Get the deployment-guide TOC @@ -97,13 +98,13 @@ public void MultipleDocumentationSetsHaveDistinctIdentifiers() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); var observabilityContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/observability", output); var observabilityDocset = DocumentationSetFile.LoadAndResolve( - observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); // Each should have its own set of identifiers diff --git a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs index 821abe8497..6b5a129be6 100644 --- a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs @@ -4,6 +4,7 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; @@ -47,7 +48,7 @@ public void CreatesDocumentationSetNavigationsFromCheckoutFolders() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -84,15 +85,15 @@ public void SiteNavigationIntegratesWithDocumentationSets() var documentationSets = new List(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); documentationSets.Add(new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance)); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); documentationSets.Add(new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance)); var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); - var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); documentationSets.Add(new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance)); // Create site navigation context (using any repository's filesystem) @@ -134,7 +135,7 @@ public void SiteNavigationWithNestedTocs() // Create DocumentationSetNavigation for platform var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); platformNav.Url.Should().Be("/"); platformNav.Index.Url.Should().Be("/"); @@ -201,7 +202,7 @@ public void SiteNavigationWithAllRepositories() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -241,7 +242,7 @@ public void DocumentationSetNavigationHasCorrectStructure() // Test observability repository structure var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); observabilityNav.NavigationTitle.Should().Be(observabilityNav.NavigationTitle); @@ -270,7 +271,7 @@ public void DocumentationSetWithNestedTocs() // Test platform repository with nested TOCs var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); platformNav.NavigationTitle.Should().Be("Platform"); @@ -300,7 +301,7 @@ public void DocumentationSetWithUnderscoreDocset() // Test serverless-security repository with _docset.yml var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); - var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var securityNav = new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance); securityNav.NavigationTitle.Should().Be("Serverless Security"); @@ -331,7 +332,7 @@ public void SiteNavigationAppliesPathPrefixToAllUrls() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); @@ -368,7 +369,7 @@ public void SiteNavigationWithNestedTocsAppliesCorrectPathPrefixes() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) }; var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); @@ -399,7 +400,7 @@ public void SiteNavigationRequiresPathPrefix() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); @@ -422,7 +423,7 @@ public void ObservabilityDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -442,7 +443,7 @@ public void ServerlessSearchDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -462,7 +463,7 @@ public void ServerlessSecurityDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -482,7 +483,7 @@ public void PlatformDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -502,7 +503,7 @@ public void ElasticsearchReferenceDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/elasticsearch-reference", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/elasticsearch-reference/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -533,7 +534,7 @@ public void AllDocumentationSetsHaveNoDiagnostics() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -554,7 +555,7 @@ public void DocumentationSetNavigationWithNestedTocsHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -580,7 +581,7 @@ public void DocumentationSetNavigationWithFoldersHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs index c79316636d..3f31fc6494 100644 --- a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; @@ -42,11 +43,11 @@ public void ConstructorCreatesSiteNavigation() // Create DocumentationSetNavigation instances for the referenced repos var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav, searchNav }; @@ -80,7 +81,7 @@ public void SiteNavigationWithNestedChildren() // Create DocumentationSetNavigation for platform var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { platformNav }; @@ -118,7 +119,7 @@ public void SitePrefixNormalizesSlashes(string? sitePrefix, string expectedRootU var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav }; @@ -154,7 +155,7 @@ public void SitePrefixAppliedToNavigationItemUrls(string? sitePrefix, string exp var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav }; @@ -213,11 +214,11 @@ public void NavigationNodeIdsAreUniqueAcrossDocsets() // Create navigation for both docsets var productAContext = SiteNavigationTestFixture.CreateContext(fileSystem, productADir, output); - var productADocsetFile = DocumentationSetFile.LoadAndResolve(productAContext.Collector, fileSystem.FileInfo.New($"{productADir}/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var productADocsetFile = DocumentationSetFile.LoadAndResolve(productAContext.Collector, fileSystem.FileInfo.New($"{productADir}/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var productANav = new DocumentationSetNavigation(productADocsetFile, productAContext, GenericDocumentationFileFactory.Instance); var productBContext = SiteNavigationTestFixture.CreateContext(fileSystem, productBDir, output); - var productBDocsetFile = DocumentationSetFile.LoadAndResolve(productBContext.Collector, fileSystem.FileInfo.New($"{productBDir}/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var productBDocsetFile = DocumentationSetFile.LoadAndResolve(productBContext.Collector, fileSystem.FileInfo.New($"{productBDir}/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var productBNav = new DocumentationSetNavigation(productBDocsetFile, productBContext, GenericDocumentationFileFactory.Instance); // Get the "getting-started" folders from each docset @@ -285,11 +286,11 @@ public void SitePrefixAppliedToMultipleNavigationItems(string? sitePrefix, strin var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav, searchNav }; diff --git a/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs b/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs index e18dc14018..7049993ca9 100644 --- a/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs +++ b/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs @@ -6,7 +6,7 @@ using AwesomeAssertions; using Elastic.Codex; using Elastic.Documentation.Configuration; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -21,8 +21,8 @@ public class CodexConfigurationLoaderTests(ITestOutputHelper output) private static readonly string ConfigPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml"); - private ScopedFileSystem ScopedFs(MockFileSystem mockFs) => - FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs); + private CheckoutsFileSystem ScopedFs(MockFileSystem mockFs) => + CheckoutsFileSystem.FromWorkingDirectory(mockFs); private TestDiagnosticsCollector Collector() => new(output); diff --git a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs index f932f80130..2650782246 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs @@ -80,7 +80,7 @@ internal sealed class TestCodexDocumentationContext(IDiagnosticsCollector collec public IFileInfo ConfigurationPath => _fileSystem.FileInfo.New(_fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectory(_fileSystem); + public ScopedFileSystem ReadFileSystem => CheckoutsFileSystem.FromWorkingDirectory(_fileSystem); public DocumentationWriteFileSystem WriteFileSystem => new(_fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fileSystem); public IDirectoryInfo OutputDirectory => _fileSystem.DirectoryInfo.New(_fileSystem.Path.Join(Paths.ApplicationData.FullName, "codex", "output")); public BuildType BuildType => BuildType.Codex; diff --git a/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs b/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs index 36f00b3902..d936af460f 100644 --- a/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs +++ b/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs @@ -6,7 +6,7 @@ using AwesomeAssertions; using Elastic.Codex.Sourcing; using Elastic.Documentation.Configuration; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -16,8 +16,8 @@ public class FindDocsetFileTests private static readonly string RepoRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, "repo"); - private static ScopedFileSystem CreateScopedFs(MockFileSystem mockFs) => - FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs); + private static CheckoutsFileSystem CreateScopedFs(MockFileSystem mockFs) => + CheckoutsFileSystem.FromWorkingDirectory(mockFs); [Fact] public void StandardPath_Found() diff --git a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs index 1941a1ed3f..a6440c2887 100644 --- a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs +++ b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs @@ -139,7 +139,7 @@ private sealed class MinimalCodexContext : ICodexDocumentationContext private readonly System.IO.Abstractions.TestingHelpers.MockFileSystem _fs = new(); public System.IO.Abstractions.IFileInfo ConfigurationPath => _fs.FileInfo.New("/codex.yml"); public Elastic.Documentation.Diagnostics.IDiagnosticsCollector Collector => new Elastic.Documentation.Diagnostics.DiagnosticsCollector([]); - public ScopedFileSystem ReadFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectory(_fs); + public ScopedFileSystem ReadFileSystem => CheckoutsFileSystem.FromWorkingDirectory(_fs); public DocumentationWriteFileSystem WriteFileSystem => new(_fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fs); public System.IO.Abstractions.IDirectoryInfo OutputDirectory => _fs.DirectoryInfo.New("/output"); public BuildType BuildType => BuildType.Codex; diff --git a/tests/Navigation.Tests/TestDocumentationSetContext.cs b/tests/Navigation.Tests/TestDocumentationSetContext.cs index 983c94c8df..3ecc53ed14 100644 --- a/tests/Navigation.Tests/TestDocumentationSetContext.cs +++ b/tests/Navigation.Tests/TestDocumentationSetContext.cs @@ -84,7 +84,7 @@ public TestDocumentationSetContext(IFileSystem fileSystem, TestDiagnosticsCollector? collector = null ) { - ReadFileSystem = FileSystemFactory.ScopeSourceDirectory(fileSystem, sourceDirectory.FullName); + ReadFileSystem = new CheckoutsFileSystem(sourceDirectory, inner: fileSystem); WriteFileSystem = new DocumentationWriteFileSystem(sourceDirectory, outputDirectory, fileSystem); DocumentationSourceDirectory = sourceDirectory; OutputDirectory = outputDirectory; diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs index 1adb5e0215..0910fc611c 100644 --- a/tests/authoring/Framework/CrossLinkResolverAssertions.fs +++ b/tests/authoring/Framework/CrossLinkResolverAssertions.fs @@ -32,7 +32,7 @@ module CrossLinkResolverAssertions = member _.Collector = collector member _.DocumentationSourceDirectory = mockFileSystem.DirectoryInfo.New("/docs") member _.Git = GitCheckoutInformation.Unavailable - member _.ReadFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFileSystem) + member _.ReadFileSystem = CheckoutsFileSystem.FromWorkingDirectory(mockFileSystem) member _.WriteFileSystem = DocumentationWriteFileSystem(mockFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, mockFileSystem) member _.ConfigurationPath = mockFileSystem.FileInfo.New("mock_docset.yml") member _.OutputDirectory = mockFileSystem.DirectoryInfo.New(".artifacts") From 6fb0b58c6e5fb1e3f9e49538fb48d078d632ef9f Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 12:05:47 +0200 Subject: [PATCH 16/29] Replace ambient FileSystemFactory statics with named filesystem types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New ConfigurationFileSystem: rooted at /config + AppData; replaces FileSystemFactory.RealRead in ConfigurationFileProvider's static factory - Remove FileSystemFactory.AppData/AppDataOptions; replace all 7 callers with new ApplicationDataFileSystem(): CheckForUpdatesMiddleware, ReloadableGeneratorState (×2), CodexBuildService, IsolatedBuildService - Narrow GitLinkIndexReader and CrossLinkFetcher optional params from ScopedFileSystem? to ApplicationDataFileSystem? - Make CsvReader.ReadCsvFile fileSystem param required (non-nullable) - Delete FileSystemFactory.RealGitRootForPath; migrate 5 command sites: IndexCommand, DiffCommands, MoveCommand (×2) → DocumentationFileSystem.Resolve StaticWebHost, InboundLinkCommands → CheckoutsFileSystem.FromWorkingDirectory PhysicalDocsetTests (×3) → DocumentationFileSystem.Resolve(cwd) - Reorder CheckoutsFileSystem ctor params: inner before extraRoots; update all Codex command and test call sites accordingly Remaining in FileSystemFactory: RealRead, RealWrite, RealGitRootForPathWrite, RealReadForRunnerTemp — all used exclusively by changelog commands/services, migrated in a follow-up commit. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Building/CodexBuildService.cs | 2 +- .../ConfigurationFileProvider.cs | 3 +- .../GitLinkIndexReader.cs | 5 +- .../CrossLinks/CrossLinkFetcher.cs | 5 +- .../FileSystemFactory.cs | 63 ------------------- .../FileSystems/CheckoutsFileSystem.cs | 28 +++------ .../FileSystems/ConfigurationFileSystem.cs | 26 ++++++++ .../Myst/Directives/CsvInclude/CsvReader.cs | 5 +- .../IsolatedBuildService.cs | 2 +- .../Commands/Codex/CodexCommands.cs | 9 +-- .../Commands/Codex/CodexIndexCommand.cs | 3 +- .../Commands/Codex/CodexSyncCommand.cs | 3 +- .../Codex/CodexUpdateRedirectsCommand.cs | 3 +- .../docs-builder/Commands/DiffCommands.cs | 3 +- .../Commands/InboundLinkCommands.cs | 3 +- .../docs-builder/Commands/IndexCommand.cs | 3 +- .../docs-builder/Commands/MoveCommand.cs | 5 +- .../Http/ReloadableGeneratorState.cs | 5 +- .../docs-builder/Http/StaticWebHost.cs | 3 +- .../Middleware/CheckForUpdatesMiddleware.cs | 3 +- .../FileSystemFactoryTests.cs | 8 +-- .../Isolation/PhysicalDocsetTests.cs | 8 +-- 22 files changed, 76 insertions(+), 122 deletions(-) create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs diff --git a/src/Elastic.Codex/Building/CodexBuildService.cs b/src/Elastic.Codex/Building/CodexBuildService.cs index 988b275cb3..705b6c85fd 100644 --- a/src/Elastic.Codex/Building/CodexBuildService.cs +++ b/src/Elastic.Codex/Building/CodexBuildService.cs @@ -70,7 +70,7 @@ public async Task BuildAll( var buildContexts = new List(); var environment = context.Configuration.Environment ?? "internal"; - using var codexLinkIndexReader = new GitLinkIndexReader(environment, FileSystemFactory.AppData, skipFetch: true); + using var codexLinkIndexReader = new GitLinkIndexReader(environment, new ApplicationDataFileSystem(), skipFetch: true); // Phase 1: Load and parse all documentation sets foreach (var checkout in cloneResult.Checkouts) diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index 45c7b676b5..1f9486d58e 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using System.Text.RegularExpressions; using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Configuration.Converters; using Elastic.Documentation.Configuration.Serialization; using Elastic.Documentation.Configuration.Toc; @@ -272,7 +273,7 @@ public static IServiceCollection AddConfigurationFileProvider(this IServiceColle { using var sp = services.BuildServiceProvider(); var logFactory = sp.GetRequiredService(); - var provider = new ConfigurationFileProvider(logFactory, FileSystemFactory.RealRead, skipPrivateRepositories, configurationSource); + var provider = new ConfigurationFileProvider(logFactory, new ConfigurationFileSystem(), skipPrivateRepositories, configurationSource); _ = services.AddSingleton(provider); configure(services, provider); return services; diff --git a/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs b/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs index d302caa764..205c6a6432 100644 --- a/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs +++ b/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.IO.Abstractions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links; using Nullean.ScopedFileSystem; @@ -27,13 +28,13 @@ public class GitLinkIndexReader : ILinkIndexReader, IDisposable private readonly SemaphoreSlim _cloneLock = new(1, 1); private bool _ensuredClone; - public GitLinkIndexReader(string environment, ScopedFileSystem? fileSystem = null, bool skipFetch = false) + public GitLinkIndexReader(string environment, ApplicationDataFileSystem? fileSystem = null, bool skipFetch = false) { if (string.IsNullOrWhiteSpace(environment)) throw new ArgumentException("Environment must be specified in the codex configuration (e.g., 'internal', 'security').", nameof(environment)); _environment = environment; - _fileSystem = fileSystem ?? FileSystemFactory.AppData; + _fileSystem = fileSystem ?? new ApplicationDataFileSystem(); _skipFetch = skipFetch; } diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs index d4dc8d5cca..1baf0e7562 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs @@ -8,6 +8,7 @@ using System.Text.Json; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Serialization; using Microsoft.Extensions.Logging; @@ -58,10 +59,10 @@ public record FetchedCrossLinks }; } -public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ScopedFileSystem? fileSystem = null) : IDisposable +public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ApplicationDataFileSystem? fileSystem = null) : IDisposable { protected ILogger Logger { get; } = logFactory.CreateLogger(nameof(CrossLinkFetcher)); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.AppData; + private readonly IFileSystem _fileSystem = fileSystem ?? new ApplicationDataFileSystem(); private LinkRegistry? _linkIndex; public static RepositoryLinks Deserialize(string json) => diff --git a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs index 55677f6434..cf318e21e6 100644 --- a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs +++ b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs @@ -3,8 +3,6 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using System.IO.Abstractions.TestingHelpers; -using Elastic.Documentation.Extensions; using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; using DirectoryInfoExtensions = Elastic.Documentation.Extensions.IDirectoryInfoExtensions; @@ -36,13 +34,6 @@ public static class FileSystemFactory AllowedSpecialFolders = AllowedSpecialFolder.Temp }; - // AppData-only options: for components that only access caches/state files. - private static readonly ScopedFileSystemOptions AppDataOptions = new([Paths.ApplicationData.FullName]) - { - // .git needed for codex-link-index clone directory inside ApplicationData - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } - }; - /// /// A pre-allocated for reading workspace files. /// Scoped to the working directory root and per-user app data; allows .git @@ -58,14 +49,6 @@ public static class FileSystemFactory /// public static ScopedFileSystem RealWrite { get; } = new(new FileSystem(), WorkingDirectoryWriteOptions); - /// - /// A pre-allocated scoped only to the per-user - /// elastic/docs-builder application data folder. Use for components that - /// access caches or state and have no need for workspace files - /// (e.g. CrossLinkFetcher, CheckForUpdatesFilter, GitLinkIndexReader). - /// - public static ScopedFileSystem AppData { get; } = new(new FileSystem(), AppDataOptions); - // Builds write options that include AllowedSpecialFolders.Temp PLUS the inner FS's own // GetTempPath() as an explicit root — but only when the inner FS is MockFileSystem. // @@ -98,52 +81,6 @@ private static ScopedFileSystemOptions BuildWriteOptions(IFileSystem inner, para }; } - /// - /// Creates a read scoped to the git root of - /// . Falls back to when - /// is . Use in commands that accept an explicit --path argument. - /// - /// Suitable for command-layer code. Service-layer tests use directly - /// and do not exercise this method. - /// - /// - public static ScopedFileSystem RealGitRootForPath(string? path) - { - var plain = new FileSystem(); - string root; - if (path is null) - root = Paths.WorkingDirectoryRoot.FullName; - else - { - var startDir = plain.DirectoryInfo.New( - plain.Directory.Exists(path) ? path : plain.Path.GetDirectoryName(path) ?? path); - root = Paths.FindGitRoot(startDir)?.FullName ?? startDir.FullName; - } - - var roots = new List { root, Paths.ApplicationData.FullName }; - - // In a git worktree the local .git entry is a file pointing to the main repo's git dir. - // Resolve it via TryReadGitDirPointer (handles relative paths and commondir) and add the - // real .git directory to the scope so config/HEAD reads are not rejected. - var gitFilePath = plain.Path.Join(root, ".git"); - if (plain.File.Exists(gitFilePath) - && Paths.TryReadGitDirPointer(plain, plain.FileInfo.New(gitFilePath), out var resolvedGitDir) - && resolvedGitDir is not null) - { - roots.Add(resolvedGitDir.FullName); - } - - // Fast path: no worktree detected and path was null — reuse the pre-built instance - if (roots.Count == 2 && path is null) - return RealRead; - - return new ScopedFileSystem(plain, new ScopedFileSystemOptions([.. roots]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } - }); - } - /// /// Creates a write scoped to the git root of /// (and if it falls outside that root). diff --git a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs index ad33abfa14..d19c26c15c 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs @@ -4,8 +4,8 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; -using Elastic.Documentation.Extensions; using Nullean.ScopedFileSystem; +using static Elastic.Documentation.Extensions.IDirectoryInfoExtensions; namespace Elastic.Documentation.FileSystems; @@ -23,11 +23,10 @@ public class CheckoutsFileSystem : ScopedFileSystem private readonly IFileSystem _inner; - public CheckoutsFileSystem( - IDirectoryInfo root, + public CheckoutsFileSystem(IDirectoryInfo root, IDirectoryInfo? output = null, - IEnumerable? extraRoots = null, - IFileSystem? inner = null) + IFileSystem? inner = null, + IEnumerable? extraRoots = null) : base(inner ?? Physical, BuildReadOptions(root, extraRoots)) { _inner = inner ?? Physical; @@ -43,9 +42,7 @@ public CheckoutsFileSystem( /// Write scope for this checkout tree. public DocumentationWriteFileSystem Write { get; } - private static ScopedFileSystemOptions BuildReadOptions( - IDirectoryInfo root, - IEnumerable? extraRoots) + private static ScopedFileSystemOptions BuildReadOptions(IDirectoryInfo root, IEnumerable? extraRoots) { var fs = root.FileSystem; var rootPath = root.FullName; @@ -55,11 +52,8 @@ private static ScopedFileSystemOptions BuildReadOptions( // (/home/runner/.local/share/elastic/docs-builder/checkouts/...), so AppData would subsume // root and the ScopedFileSystem constructor would throw. var appData = Paths.ApplicationData.FullName; - if (!IDirectoryInfoExtensions.IsSubPath(appData, rootPath, fs) - && !IDirectoryInfoExtensions.IsSubPath(rootPath, appData, fs)) - { + if (!IsSubPath(appData, rootPath, fs) && !IsSubPath(rootPath, appData, fs)) roots.Add(appData); - } if (extraRoots is not null) { @@ -68,12 +62,8 @@ private static ScopedFileSystemOptions BuildReadOptions( if (string.IsNullOrEmpty(extra)) continue; // Drop descendants of root (already covered) and ancestors (would subsume root, causing overlap). - if (!IDirectoryInfoExtensions.IsSubPath(extra, rootPath, fs) - && !IDirectoryInfoExtensions.IsSubPath(rootPath, extra, fs) - && !roots.Contains(extra, StringComparer.OrdinalIgnoreCase)) - { + if (!IsSubPath(extra, rootPath, fs) && !IsSubPath(rootPath, extra, fs) && !roots.Contains(extra, StringComparer.OrdinalIgnoreCase)) roots.Add(extra); - } } } @@ -90,7 +80,5 @@ private static ScopedFileSystemOptions BuildReadOptions( /// /// The underlying filesystem. Defaults to a new when . public static CheckoutsFileSystem FromWorkingDirectory(IFileSystem? inner = null) => - new( - (inner ?? Physical).DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - inner: inner); + new((inner ?? Physical).DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: inner); } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs new file mode 100644 index 0000000000..45e1bb0981 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// A scoped filesystem covering the docs configuration tree (<cwd>/config) and +/// per-user application data. Used by ConfigurationFileProvider, which reads +/// config/*.yml and writes runtime artefacts under AppData/config-runtime. +/// +public class ConfigurationFileSystem(IFileSystem? inner = null) : ScopedFileSystem( + inner ?? new FileSystem(), + new ScopedFileSystemOptions([ + System.IO.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config"), + Paths.ApplicationData.FullName + ]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }) +{ +} diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs index 12f6b1d68f..e60b3bc3e6 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs @@ -11,10 +11,9 @@ namespace Elastic.Markdown.Myst.Directives.CsvInclude; public static class CsvReader { - public static IEnumerable ReadCsvFile(string filePath, string separator, ScopedFileSystem? fileSystem = null) + public static IEnumerable ReadCsvFile(string filePath, string separator, ScopedFileSystem fileSystem) { - var fs = fileSystem ?? FileSystemFactory.RealRead; - return ReadWithSep(filePath, separator, fs); + return ReadWithSep(filePath, separator, fileSystem); } private static IEnumerable ReadWithSep(string filePath, string separator, IFileSystem fileSystem) diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index 3b754d11a4..442976e3a9 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -135,7 +135,7 @@ public async Task Build( else { using var codexReader = context.Configuration.Registry != DocSetRegistry.Public - ? new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), FileSystemFactory.AppData) + ? new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), new ApplicationDataFileSystem()) : null; var crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher( diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 7a83bae01e..75e76ab05e 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -64,8 +64,7 @@ public async Task CloneAndBuild( var fs = new CheckoutsFileSystem( plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), output is null ? null : plain.DirectoryInfo.New(output.FullName), - extraRoots: [gitRoot], - inner: plain); + inner: plain, extraRoots: [gitRoot]); var configFile = fs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) @@ -126,8 +125,7 @@ public async Task Clone( var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; var fs = new CheckoutsFileSystem( plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - extraRoots: [gitRoot], - inner: plain); + inner: plain, extraRoots: [gitRoot]); var configFile = fs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) @@ -166,8 +164,7 @@ public async Task Build( var fs = new CheckoutsFileSystem( plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), output is null ? null : plain.DirectoryInfo.New(output.FullName), - extraRoots: [gitRoot], - inner: plain); + inner: plain, extraRoots: [gitRoot]); var configFile = fs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out _)) diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 212ff706ec..06622288c1 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -48,8 +48,7 @@ public async Task Index( var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; var fs = new CheckoutsFileSystem( plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - extraRoots: [gitRoot], - inner: plain); + inner: plain, extraRoots: [gitRoot]); var configFile = fs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; diff --git a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs index 2b22729fa8..305d2a9995 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs @@ -91,8 +91,7 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; var fs = new CheckoutsFileSystem( plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - extraRoots: [gitRoot], - inner: plain); + inner: plain, extraRoots: [gitRoot]); var configFile = fs.FileInfo.New(config.FullName); var codexConfig = CodexConfiguration.Load(configFile); return (new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, null), diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index 6eb4139a49..b5517d24a9 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -40,8 +40,7 @@ public async Task UpdateRedirects( var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; var fs = new CheckoutsFileSystem( plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - extraRoots: [gitRoot], - inner: plain); + inner: plain, extraRoots: [gitRoot]); var configFile = fs.FileInfo.New(config.FullName); if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig)) return 1; diff --git a/src/tooling/docs-builder/Commands/DiffCommands.cs b/src/tooling/docs-builder/Commands/DiffCommands.cs index e6196a3ca4..2077dded77 100644 --- a/src/tooling/docs-builder/Commands/DiffCommands.cs +++ b/src/tooling/docs-builder/Commands/DiffCommands.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Refactor.Tracking; using Elastic.Documentation.Services; @@ -32,7 +33,7 @@ public async Task Validate(string? path = null, CancellationToken ct = defa await using var serviceInvoker = new ServiceInvoker(collector); var service = new LocalChangeTrackingService(logFactory, configurationContext); - var fs = FileSystemFactory.RealGitRootForPath(path); + var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); serviceInvoker.AddCommand(service, (path, fs), async static (s, collector, state, _) => await s.ValidateRedirects(collector, state.path, state.fs) diff --git a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs index e208eea64f..8243df58bb 100644 --- a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs +++ b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs @@ -6,6 +6,7 @@ using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Links.InboundLinks; using Elastic.Documentation.Services; @@ -24,7 +25,7 @@ namespace Documentation.Builder.Commands; /// internal sealed class InboundLinkCommands(ILoggerFactory logFactory, IDiagnosticsCollector collector) { - private readonly LinkIndexService _linkIndexService = new(logFactory, FileSystemFactory.RealRead); + private readonly LinkIndexService _linkIndexService = new(logFactory, CheckoutsFileSystem.FromWorkingDirectory()); /// Validate all cross-links across every published links.json in the registry. [NoOptionsInjection] diff --git a/src/tooling/docs-builder/Commands/IndexCommand.cs b/src/tooling/docs-builder/Commands/IndexCommand.cs index 34e1489d10..396d2ec31d 100644 --- a/src/tooling/docs-builder/Commands/IndexCommand.cs +++ b/src/tooling/docs-builder/Commands/IndexCommand.cs @@ -5,6 +5,7 @@ using Actions.Core.Services; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Isolated; using Elastic.Documentation.Services; @@ -40,7 +41,7 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealGitRootForPath(path); + var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); var service = new IsolatedIndexService(logFactory, configurationContext, githubActionsService, environmentVariables); serviceInvoker.AddCommand(service, async (s, col, ctx) => await s.Index(col, fs, es, path, ctx) diff --git a/src/tooling/docs-builder/Commands/MoveCommand.cs b/src/tooling/docs-builder/Commands/MoveCommand.cs index 2f5ac7a901..dda54d00a4 100644 --- a/src/tooling/docs-builder/Commands/MoveCommand.cs +++ b/src/tooling/docs-builder/Commands/MoveCommand.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Refactor; using Elastic.Documentation.Services; @@ -40,7 +41,7 @@ public async Task Move( await using var serviceInvoker = new ServiceInvoker(collector); var service = new MoveFileService(logFactory, configurationContext); - var fs = FileSystemFactory.RealGitRootForPath(path); + var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); serviceInvoker.AddCommand(service, (source, target, dryRun, path, fs), async static (s, collector, state, ctx) => await s.Move(collector, state.source, state.target, state.dryRun, state.path, state.fs, ctx) @@ -73,7 +74,7 @@ public async Task Format( await using var serviceInvoker = new ServiceInvoker(collector); var service = new FormatService(logFactory, configurationContext); - var fs = FileSystemFactory.RealGitRootForPath(path); + var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); serviceInvoker.AddCommand(service, (path, check, fs), async static (s, collector, state, ctx) => await s.Format(collector, state.path, state.check, state.fs, ctx) diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 6ed55dd6bb..7edcd956cb 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; using Elastic.ApiExplorer; +using Elastic.Documentation.FileSystems; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; @@ -47,7 +48,7 @@ bool isWatchBuild ApiPath = context.WriteFileSystem.DirectoryInfo.New(Path.Join(outputPath.FullName, "api")); if (context.Configuration.Registry != DocSetRegistry.Public) - _codexReader = new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), FileSystemFactory.AppData); + _codexReader = new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), new ApplicationDataFileSystem()); _crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(logFactory, _context.Configuration, codexLinkIndexReader: _codexReader); // we pass NoopCrossLinkResolver.Instance here because `ReloadAsync` will always be called when the is started. @@ -80,7 +81,7 @@ public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true) _context.ReloadConfiguration(); (_codexReader as IDisposable)?.Dispose(); _codexReader = _context.Configuration.Registry != DocSetRegistry.Public - ? new GitLinkIndexReader(_context.Configuration.Registry.ToStringFast(true), FileSystemFactory.AppData) + ? new GitLinkIndexReader(_context.Configuration.Registry.ToStringFast(true), new ApplicationDataFileSystem()) : null; _crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(_logFactory, _context.Configuration, codexLinkIndexReader: _codexReader); } diff --git a/src/tooling/docs-builder/Http/StaticWebHost.cs b/src/tooling/docs-builder/Http/StaticWebHost.cs index a0d99a67b8..9b2638242e 100644 --- a/src/tooling/docs-builder/Http/StaticWebHost.cs +++ b/src/tooling/docs-builder/Http/StaticWebHost.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Api; #endif using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Extensions; using Elastic.Documentation.ServiceDefaults; using Microsoft.AspNetCore.Builder; @@ -25,7 +26,7 @@ public class StaticWebHost public StaticWebHost(int port, string? path) { _contentRoot = path ?? Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); - var fs = FileSystemFactory.RealGitRootForPath(_contentRoot); + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var dir = fs.DirectoryInfo.New(_contentRoot); if (!dir.Exists) throw new Exception($"Can not serve empty directory: {_contentRoot}"); diff --git a/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs index 35670eadd2..cdf10b745b 100644 --- a/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs +++ b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs @@ -6,6 +6,7 @@ using System.Reflection; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging; using Nullean.Argh.Middleware; @@ -15,7 +16,7 @@ namespace Documentation.Builder.Middleware; internal sealed class CheckForUpdatesMiddleware(ILogger logger) : ICommandMiddleware { // Only accesses ApplicationData — no workspace access needed - private static readonly IFileSystem Fs = FileSystemFactory.AppData; + private static readonly ApplicationDataFileSystem Fs = new(); private readonly IFileInfo _stateFile = Fs.FileInfo.New(Path.Join(Paths.ApplicationData.FullName, "docs-build-check.state")); private readonly ILogger _logger = logger; diff --git a/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs index 96bafff2fb..1d1316e126 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs @@ -29,7 +29,7 @@ public void CheckoutsFileSystem_NestedExtensionRoot_DoesNotThrow() { configPath, new MockFileData("environment: internal") } }); - var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), extraRoots: [nestedConfigDir], inner: mockFs); + var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), inner: mockFs, extraRoots: [nestedConfigDir]); act.Should().NotThrow(); var scoped = act(); @@ -47,7 +47,7 @@ public void CheckoutsFileSystem_ExternalExtensionRoot_AllowsReadingExternalConfi { configPath, new MockFileData("environment: internal") } }); - var scoped = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), extraRoots: [externalRoot], inner: mockFs); + var scoped = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), inner: mockFs, extraRoots: [externalRoot]); scoped.File.Exists(configPath).Should().BeTrue(); } @@ -61,7 +61,7 @@ public void CheckoutsFileSystem_AncestorExtensionRoot_DoesNotThrow() var ancestor = Path.GetDirectoryName(workingRoot)!; var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = workingRoot }); - var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), extraRoots: [ancestor], inner: mockFs); + var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), inner: mockFs, extraRoots: [ancestor]); act.Should().NotThrow(); var scoped = act(); @@ -98,7 +98,7 @@ public void RealReadForRunnerTemp_RunnerTempSibling_AllowsReadingStagedFile() var scoped = FileSystemFactory.RealReadForRunnerTemp(env); // We call the overload that accepts inner FS, reusing the factory helper // that RealReadForRunnerTemp delegates to. - var scopedMock = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), extraRoots: [tempDir], inner: mockFs); + var scopedMock = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: mockFs, extraRoots: [tempDir]); scopedMock.File.Exists(stagedFile).Should().BeTrue(); } diff --git a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs index ea7bc41fbe..3c3dbf4e6c 100644 --- a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs +++ b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs @@ -7,10 +7,10 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Isolated.Leaf; using Elastic.Documentation.Navigation.Isolated.Node; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests.Isolation; @@ -68,7 +68,7 @@ public async Task PhysicalDocsetNavigationHasCorrectUrls() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, FileSystemFactory.RealGitRootForPath(null)); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName)); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); @@ -94,7 +94,7 @@ public async Task PhysicalDocsetNavigationIncludesNestedTocs() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, FileSystemFactory.RealGitRootForPath(null)); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName)); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); @@ -126,7 +126,7 @@ public async Task PhysicalDocsetNavigationHandlesHiddenFiles() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, FileSystemFactory.RealGitRootForPath(null)); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName)); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); From f080d4613f3997f97867c173408bc011b3d3dede Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 12:25:31 +0200 Subject: [PATCH 17/29] Collapse assembler/codex contexts to single CheckoutsFileSystem param Introduces AssemblyWriteFileSystem (sibling of DocumentationWriteFileSystem, unrelated by inheritance for slot type safety) and simplifies both AssembleContext and CodexContext to receive a single CheckoutsFileSystem instead of separate read+write params: Before: AssembleContext(... readFs, writeFs, checkoutDir?, output?) After: AssembleContext(... fileSystem, checkoutDir?, output?) - New AssemblyWriteFileSystem in Elastic.Documentation/FileSystems/, roots: checkouts dir + output dir (disjointness-filtered) + Temp; replaces the assembler's use of DocumentationWriteFileSystem in a future commit when IDocsSyncContext is narrowed (commit 6) - AssembleContext and CodexContext now derive ReadFileSystem / WriteFileSystem from the single CheckoutsFileSystem; path derivation stays in the ctor - Explicit ScopedFileSystem interface implementations bridge the current IDocumentationContext/IDocsSyncContext ReadFileSystem type until commit 6 narrows the interface members - All 7 assembler/codex service methods that previously took (CheckoutsFileSystem readFs, DocumentationWriteFileSystem writeFs) now take a single (CheckoutsFileSystem fileSystem) - All 10 command + 7 service construction sites updated; readFs/writeFs intermediates removed from command closures - Integration tests: remove the redundant fs.Write argument from all 13 AssembleContext and CodexContext constructions (2 that pass explicit paths keep their path strings; 11 null/null sites become single-arg) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Elastic.Codex/CodexContext.cs | 21 ++-- .../FileSystems/AssemblyWriteFileSystem.cs | 96 +++++++++++++++++++ .../AssembleContext.cs | 24 +++-- .../Building/AssemblerBuildService.cs | 11 +-- .../Building/AssemblerSitemapService.cs | 5 +- .../RepositoryBuildMatchingService.cs | 2 +- .../RepositoryPublishValidationService.cs | 2 +- .../Indexing/AssemblerAiEnrichService.cs | 7 +- .../Indexing/AssemblerIndexService.cs | 7 +- .../Navigation/GlobalNavigationService.cs | 4 +- .../Sourcing/AssemblerCloneService.cs | 2 +- .../Assembler/AssemblerAiEnrichCommand.cs | 6 +- .../Commands/Assembler/AssemblerCommands.cs | 12 +-- .../Assembler/AssemblerIndexCommand.cs | 6 +- .../Commands/Assembler/DeployCommands.cs | 4 +- .../Commands/Codex/CodexCommands.cs | 14 +-- .../Commands/Codex/CodexIndexCommand.cs | 6 +- .../Commands/Codex/CodexSyncCommand.cs | 2 +- .../Codex/CodexUpdateRedirectsCommand.cs | 2 +- .../AssemblerConfigurationTests.cs | 4 +- .../DocsSyncTests.cs | 6 +- .../IncrementalDeployRoundTripTests.cs | 6 +- .../NavigationBuildingTests.cs | 2 +- .../NavigationRootTests.cs | 2 +- .../SiteNavigationTests.cs | 6 +- 25 files changed, 175 insertions(+), 84 deletions(-) create mode 100644 src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs diff --git a/src/Elastic.Codex/CodexContext.cs b/src/Elastic.Codex/CodexContext.cs index 503055c787..3ff78acf97 100644 --- a/src/Elastic.Codex/CodexContext.cs +++ b/src/Elastic.Codex/CodexContext.cs @@ -17,7 +17,11 @@ namespace Elastic.Codex; /// public class CodexContext : IDocsSyncContext { - public ScopedFileSystem ReadFileSystem { get; } + // Explicit implementation satisfies the interface contract; public property exposes + // the narrower type. Removed when IDocsSyncContext.ReadFileSystem narrows to + // CheckoutsFileSystem in commit 6. + ScopedFileSystem IDocsSyncContext.ReadFileSystem => ReadFileSystem; + public CheckoutsFileSystem ReadFileSystem { get; } public DocumentationWriteFileSystem WriteFileSystem { get; } public IDiagnosticsCollector Collector { get; } public CodexConfiguration Configuration { get; } @@ -38,24 +42,23 @@ public CodexContext( CodexConfiguration configuration, IFileInfo configurationPath, IDiagnosticsCollector collector, - CheckoutsFileSystem readFileSystem, - DocumentationWriteFileSystem writeFileSystem, - string? checkoutDirectory, - string? outputDirectory + CheckoutsFileSystem fileSystem, + string? checkoutDirectory = null, + string? outputDirectory = null ) { Configuration = configuration; ConfigurationPath = configurationPath; Collector = collector; - ReadFileSystem = readFileSystem; - WriteFileSystem = writeFileSystem; + ReadFileSystem = fileSystem; + WriteFileSystem = fileSystem.Write; EnvironmentName = string.IsNullOrEmpty(configuration.Environment) ? "codex" : configuration.Environment; var defaultCheckoutDirectory = Path.Join(Paths.ApplicationData.FullName, "codex", "clone"); CheckoutDirectory = checkoutDirectory is null - ? readFileSystem.DirectoryInfo.New(defaultCheckoutDirectory) - : readFileSystem.DirectoryInfo.New(checkoutDirectory); + ? fileSystem.DirectoryInfo.New(defaultCheckoutDirectory) + : fileSystem.DirectoryInfo.New(checkoutDirectory); var defaultOutputDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "codex", "docs"); OutputDirectory = WriteFileSystem.DirectoryInfo.New(outputDirectory ?? defaultOutputDirectory); diff --git a/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs b/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs new file mode 100644 index 0000000000..54c101f61e --- /dev/null +++ b/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs @@ -0,0 +1,96 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Extensions; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Write scope for the assembler and codex pipeline. Sibling of DocumentationWriteFileSystem +/// under ScopedFileSystem — it does not derive from +/// DocumentationWriteFileSystem so that write slots typed to one cannot silently accept the other. +/// +/// Roots: the checkouts directory and the output directory (if they are not already nested inside each +/// other), plus for S3 upload staging. +/// +/// +/// The checkouts root (e.g. AppData/checkouts/<source>). +/// +/// Optional explicit output directory. When it falls outside , it is added +/// as a second scope root; when , output is assumed to be under +/// /.artifacts and is therefore already covered. +/// +/// +/// The underlying filesystem. Defaults to a new when . +/// Pass a mock in tests. +/// +public class AssemblyWriteFileSystem( + IDirectoryInfo checkout, + IDirectoryInfo? output = null, + IFileSystem? inner = null) + : ScopedFileSystem(inner ?? new FileSystem(), BuildOptions(checkout, output, inner)) +{ + /// + /// The per-user application data directory for elastic/docs-builder. + /// Computed inline (rather than via Paths) to avoid a circular project reference. + /// + private static string ApplicationDataPath + { + get + { + var localPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(localPath)) + localPath = System.IO.Path.GetTempPath(); + return System.IO.Path.Join(localPath, "elastic", "docs-builder"); + } + } + + private static ScopedFileSystemOptions BuildOptions( + IDirectoryInfo checkout, + IDirectoryInfo? output, + IFileSystem? inner) + { + var fs = inner ?? checkout.FileSystem; + var checkoutPath = checkout.FullName; + var roots = new List { checkoutPath }; + + // AppData is disjointness-filtered: on CI the checkouts directory lives inside AppData + // (/home/runner/.local/share/elastic/docs-builder/checkouts/...), so AppData would subsume + // checkoutPath and trigger ValidateRootsAreDisjoint. + var appData = ApplicationDataPath; + if (!IDirectoryInfoExtensions.IsSubPath(appData, checkoutPath, fs) + && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, appData, fs)) + { + roots.Add(appData); + } + + if (output is not null && !IDirectoryInfoExtensions.IsSubPath(output.FullName, checkoutPath, fs)) + roots.Add(output.FullName); + + // On non-Windows, MockFileSystem hardcodes a Unix-ified path ("/temp/", derived from "C:\temp") + // instead of calling System.IO.Path.GetTempPath(). AllowedSpecialFolder.Temp uses the real + // GetTempPath() (e.g. "/tmp/" on Linux), so the two diverge and scope validation fails for any + // path created via mockFs.Path.GetTempPath(). + // + // Fix tracked upstream: https://github.com/TestableIO/System.IO.Abstractions/pull/1454 + // Once that ships and we update the package reference we can drop this workaround. + var innerType = fs is ScopedFileSystem sf ? sf.InnerType : fs.GetType(); + if (!OperatingSystem.IsWindows() && innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) + { + var innerTemp = fs.Path.GetTempPath().TrimEnd( + System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); + if (!string.IsNullOrEmpty(innerTemp) && !roots.Contains(innerTemp, StringComparer.OrdinalIgnoreCase)) + roots.Add(innerTemp); + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" }, + AllowedSpecialFolders = AllowedSpecialFolder.Temp + }; + } +} diff --git a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs index dc0c4bbca1..794cd09214 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs @@ -18,7 +18,14 @@ namespace Elastic.Documentation.Assembler; public class AssembleContext : IDocumentationConfigurationContext, IDocsSyncContext { - public ScopedFileSystem ReadFileSystem { get; } + // Explicit implementations satisfy the interface contracts (ScopedFileSystem); + // the public property exposes the narrower CheckoutsFileSystem for code that knows + // the concrete context type. Commit 6 removes ReadFileSystem from IDocumentationContext + // and retypes IDocsSyncContext.ReadFileSystem to CheckoutsFileSystem, at which point + // these explicit implementations can be deleted. + ScopedFileSystem IDocumentationContext.ReadFileSystem => ReadFileSystem; + ScopedFileSystem IDocsSyncContext.ReadFileSystem => ReadFileSystem; + public CheckoutsFileSystem ReadFileSystem { get; } public DocumentationWriteFileSystem WriteFileSystem { get; } public IDiagnosticsCollector Collector { get; } @@ -67,15 +74,14 @@ public AssembleContext( IConfigurationContext configurationContext, string environment, IDiagnosticsCollector collector, - CheckoutsFileSystem readFileSystem, - DocumentationWriteFileSystem writeFileSystem, - string? checkoutDirectory, - string? output + CheckoutsFileSystem fileSystem, + string? checkoutDirectory = null, + string? output = null ) { Collector = collector; - ReadFileSystem = readFileSystem; - WriteFileSystem = writeFileSystem; + ReadFileSystem = fileSystem; + WriteFileSystem = fileSystem.Write; Configuration = configuration; ConfigurationFileProvider = configurationContext.ConfigurationFileProvider; @@ -96,8 +102,8 @@ public AssembleContext( var contentSource = Environment.ContentSource.ToStringFast(true); var defaultCheckoutDirectory = Path.Join(Paths.ApplicationData.FullName, "checkouts", contentSource); CheckoutDirectory = checkoutDirectory is null - ? readFileSystem.DirectoryInfo.New(defaultCheckoutDirectory) - : readFileSystem.DirectoryInfo.New(checkoutDirectory); + ? fileSystem.DirectoryInfo.New(defaultCheckoutDirectory) + : fileSystem.DirectoryInfo.New(checkoutDirectory); var defaultOutputDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); OutputDirectory = WriteFileSystem.DirectoryInfo.New(output ?? defaultOutputDirectory); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 921efd958a..610d47272b 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -34,8 +34,7 @@ IEnvironmentVariables environmentVariables public async Task BuildAll( IDiagnosticsCollector collector, AssemblerBuildOptions options, - CheckoutsFileSystem readFs, - DocumentationWriteFileSystem writeFs, + CheckoutsFileSystem fileSystem, Cancel ctx ) { @@ -62,7 +61,7 @@ Cancel ctx _logger.LogInformation("Creating assemble context"); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, readFs, writeFs, null, null); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fileSystem); // --assume-build is not allowed on CI: it could serve stale content from a previous/cached build // CI builds must always produce fresh, reproducible output @@ -73,7 +72,7 @@ Cancel ctx if (assumeBuild.GetValueOrDefault(false)) { var indexHtmlPath = Path.Join(assembleContext.OutputDirectory.FullName, "docs", "index.html"); - if (assembleContext.OutputDirectory.Exists && readFs.File.Exists(indexHtmlPath)) + if (assembleContext.OutputDirectory.Exists && fileSystem.File.Exists(indexHtmlPath)) { _logger.LogInformation("Assuming build already exists (--assume-build). Found index.html at {Path}. Skipping build.", indexHtmlPath); return true; @@ -106,7 +105,7 @@ Cancel ctx var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, exporters, ctx); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; - var siteNavigationFile = SiteNavigationFile.Deserialize(await readFs.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); + var siteNavigationFile = SiteNavigationFile.Deserialize(await fileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray(); var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets, assembleContext.Environment.PathPrefix); @@ -128,7 +127,7 @@ Cancel ctx await cloner.WriteLinkRegistrySnapshot(checkoutResult.LinkRegistrySnapshot, ctx); var redirectsPath = Path.Join(assembleContext.OutputDirectory.FullName, "redirects.json"); - if (writeFs.File.Exists(redirectsPath)) + if (assembleContext.WriteFileSystem.File.Exists(redirectsPath)) await githubActionsService.SetOutputAsync("redirects-artifact-path", redirectsPath); if (exporters.Contains(Exporter.Html)) diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs index 791c08fcc6..7542c7bfd5 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs @@ -38,10 +38,7 @@ public async Task GenerateSitemapAsync( _logger.LogInformation("Generating sitemap from ES index for environment {Environment}", environment); - var assembleContext = new AssembleContext( - assemblyConfiguration, configurationContext, environment, collector, - fileSystem.Read, fileSystem.Write, null, null - ); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fileSystem); var cfg = configurationContext.Endpoints.Elasticsearch; await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs index f53d398442..300e51a061 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs @@ -71,7 +71,7 @@ public async Task ShouldBuild(IDiagnosticsCollector collector, string? rep var linkRegistry = await GetRegistryWithRetry(linkIndexProvider, ctx); var alreadyPublishing = linkRegistry.Repositories.ContainsKey(repositoryName); _logger.LogInformation("'{Repository}' (registry key: '{RepositoryName}') publishing to link registry: {PublishState} ", repo, repositoryName, alreadyPublishing); - var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem.Read, fileSystem.Write, null, null); + var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem); var product = assembleContext.ProductsConfiguration.GetProductByRepositoryName(repo); var matches = assembleContext.Configuration.Match(logFactory, repo, refName, product, alreadyPublishing); if (matches is { Current: null, Next: null, Edge: null, Speculative: false }) diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs index 0f44cd2e41..45dc7bb2b7 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs @@ -27,7 +27,7 @@ CheckoutsFileSystem fileSystem public async Task ValidatePublishStatus(IDiagnosticsCollector collector, Cancel ctx) { // environment does not matter to check the configuration, defaulting to dev - var context = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem.Read, fileSystem.Write, null, null); + var context = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem); ILinkIndexReader linkIndexReader = Aws3LinkIndexReader.CreateAnonymous(); var fetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexReader); var links = await fetcher.FetchLinkRegistry(ctx); diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs index abe3b76c0c..36de6f119c 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs @@ -34,8 +34,7 @@ ICoreService githubActionsService /// public async Task AiEnrich( IDiagnosticsCollector collector, - CheckoutsFileSystem readFs, - DocumentationWriteFileSystem writeFs, + CheckoutsFileSystem fileSystem, ElasticsearchIndexOptions es, string? environment, bool bootstrapOnly, @@ -43,12 +42,12 @@ Cancel ctx ) { var cfg = configurationContext.Endpoints.Elasticsearch; - await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, readFs, ctx); + await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); var githubEnvironmentInput = githubActionsService.GetInput("environment"); environment ??= !string.IsNullOrEmpty(githubEnvironmentInput) ? githubEnvironmentInput : "dev"; - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, readFs, writeFs, null, null); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fileSystem); using var exporter = new ElasticsearchMarkdownExporter(logFactory, collector, assembleContext.Endpoints, assembleContext); if (!exporter.AiEnrichmentEnabled) diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs index ff39788905..a77c7347a2 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs @@ -27,15 +27,14 @@ IEnvironmentVariables environmentVariables /// Index assembled documentation to Elasticsearch. public async Task Index( IDiagnosticsCollector collector, - CheckoutsFileSystem readFs, - DocumentationWriteFileSystem writeFs, + CheckoutsFileSystem fileSystem, ElasticsearchIndexOptions es, string? environment = null, Cancel ctx = default ) { var cfg = _configurationContext.Endpoints.Elasticsearch; - await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, readFs, ctx); + await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); return await BuildAll(collector, new AssemblerBuildOptions { @@ -45,6 +44,6 @@ public async Task Index( ShowHints = false, Exporters = new HashSet { Elasticsearch }, AssumeBuild = false - }, readFs, writeFs, ctx); + }, fileSystem, ctx); } } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs index 347d577646..42a9df636e 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs @@ -22,7 +22,7 @@ CheckoutsFileSystem fileSystem { public async Task Validate(IDiagnosticsCollector collector, Cancel ctx) { - var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem.Read, fileSystem.Write, null, null); + var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem); var namespaceChecker = new NavigationPrefixChecker(logFactory, assembleContext); var navigationFileInfo = assembleContext.ConfigurationFileProvider.NavigationFile; @@ -40,7 +40,7 @@ public async Task Validate(IDiagnosticsCollector collector, Cancel ctx) public async Task ValidateLocalLinkReference(IDiagnosticsCollector collector, string? file, Cancel ctx) { file ??= ".artifacts/docs/html/links.json"; - var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem.Read, fileSystem.Write, null, null); + var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem); var root = fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); var repository = GitCheckoutInformationFactory.Create(root, fileSystem, logFactory.CreateLogger(nameof(GitCheckoutInformation))).RepositoryName diff --git a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs index 97cbd783ab..4927842002 100644 --- a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs +++ b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs @@ -27,7 +27,7 @@ public async Task CloneAll(IDiagnosticsCollector collector, AssemblerClone var environment = options.Environment ?? (!string.IsNullOrEmpty(githubEnvironmentInput) ? githubEnvironmentInput : "dev"); var cfs = CheckoutsFileSystem.FromWorkingDirectory(); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, cfs.Read, cfs.Write, null, null); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, cfs); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); _ = await cloner.CloneAll(options.FetchLatest ?? false, options.AssumeCloned ?? false, ctx); diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs index 081b90e33d..09094129f4 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs @@ -44,12 +44,10 @@ public async Task AiEnrich( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var aifs = CheckoutsFileSystem.FromWorkingDirectory(); - var readFs = aifs.Read; - var writeFs = aifs.Write; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new AssemblerAiEnrichService(logFactory, configuration, configurationContext, githubActionsService); serviceInvoker.AddCommand(service, - async (s, col, ctx) => await s.AiEnrich(col, readFs, writeFs, es, environment, bootstrapOnly, ctx) + async (s, col, ctx) => await s.AiEnrich(col, fs, es, environment, bootstrapOnly, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index d1c53b6ee2..63927b844c 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -64,11 +64,9 @@ static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) ); var fs = CheckoutsFileSystem.FromWorkingDirectory(); - var readFs = fs.Read; - var writeFs = fs.Write; var buildService = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(buildService, (buildOptions, readFs, writeFs), buildOptions.Strict ?? false, - static async (s, col, state, ctx) => await s.BuildAll(col, state.buildOptions, state.readFs, state.writeFs, ctx) + serviceInvoker.AddCommand(buildService, (buildOptions, fs), buildOptions.Strict ?? false, + static async (s, col, state, ctx) => await s.BuildAll(col, state.buildOptions, state.fs, ctx) ); var result = await serviceInvoker.InvokeAsync(ct); @@ -149,11 +147,9 @@ public async Task Build( { await using var serviceInvoker = new ServiceInvoker(collector); var fs = CheckoutsFileSystem.FromWorkingDirectory(); - var readFs = fs.Read; - var writeFs = fs.Write; var service = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(service, (options, readFs, writeFs), options.Strict ?? false, - static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state.readFs, state.writeFs, ctx) + serviceInvoker.AddCommand(service, (options, fs), options.Strict ?? false, + static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state.fs, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs index 4f7b9aadb2..c384c647be 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs @@ -43,12 +43,10 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var ifs = CheckoutsFileSystem.FromWorkingDirectory(); - var readFs = ifs.Read; - var writeFs = ifs.Write; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new AssemblerIndexService(logFactory, configuration, configurationContext, githubActionsService, environmentVariables); serviceInvoker.AddCommand(service, - async (s, col, ctx) => await s.Index(col, readFs, writeFs, es, environment, ctx) + async (s, col, ctx) => await s.Index(col, fs, es, environment, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index 392d8c0e51..30d3f661a0 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -45,7 +45,7 @@ public async Task Plan(string environment, string s3BucketName, [ExpandUser await using var serviceInvoker = new ServiceInvoker(collector); var fs = CheckoutsFileSystem.FromWorkingDirectory(); - var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs.Read, fs.Write, null, null); + var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs); var service = new IncrementalDeployService(logFactory, githubActionsService); serviceInvoker.AddCommand(service, (context, s3BucketName, @out, deleteThreshold), static async (s, collector, state, ctx) => await s.Plan(collector, state.context, state.s3BucketName, state.@out?.FullName ?? "", state.deleteThreshold, [], ctx) @@ -67,7 +67,7 @@ public async Task Apply(string environment, string s3BucketName, [Existing, await using var serviceInvoker = new ServiceInvoker(collector); var fs = CheckoutsFileSystem.FromWorkingDirectory(); - var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs.Read, fs.Write, null, null); + var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs); var service = new IncrementalDeployService(logFactory, githubActionsService); serviceInvoker.AddCommand(service, (context, s3BucketName, planFile), static async (s, collector, state, ctx) => await s.Apply(collector, state.context, state.s3BucketName, state.planFile.FullName, ctx) diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 75e76ab05e..97bc178849 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -70,7 +70,7 @@ public async Task CloneAndBuild( if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, output?.FullName); + var codexContext = new CodexContext(codexConfig, configFile, collector, fs, null, output?.FullName); using var linkIndexReader = new GitLinkIndexReader(environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); @@ -85,12 +85,12 @@ public async Task CloneAndBuild( var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var buildService = new CodexBuildService(logFactory, configurationContext, isolatedBuildService); - serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, readFs: fs.Read), strict, + serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, fs), strict, async (s, col, state, c) => { if (state.cloneResult == null) return false; - var result = await s.BuildAll(state.codexContext, state.cloneResult, state.readFs, c); + var result = await s.BuildAll(state.codexContext, state.cloneResult, state.fs, c); return result.DocumentationSets.Count > 0; }); @@ -131,7 +131,7 @@ public async Task Clone( if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, null); + var codexContext = new CodexContext(codexConfig, configFile, collector, fs); using var linkIndexReader = new GitLinkIndexReader(environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); @@ -170,7 +170,7 @@ public async Task Build( if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out _)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, output?.FullName); + var codexContext = new CodexContext(codexConfig, configFile, collector, fs, null, output?.FullName); var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); if (cloneResult == null || cloneResult.Checkouts.Count == 0) @@ -181,10 +181,10 @@ public async Task Build( var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var buildService = new CodexBuildService(logFactory, configurationContext, isolatedBuildService); - serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, readFs: fs.Read), strict, + serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, fs), strict, async (s, col, state, c) => { - var result = await s.BuildAll(state.codexContext, state.cloneResult, state.readFs, c); + var result = await s.BuildAll(state.codexContext, state.cloneResult, state.fs, c); return result.DocumentationSets.Count > 0; }); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 06622288c1..cf2c757e02 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -53,7 +53,7 @@ public async Task Index( if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, null); + var codexContext = new CodexContext(codexConfig, configFile, collector, fs); var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); @@ -65,9 +65,9 @@ public async Task Index( var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var service = new CodexIndexService(logFactory, configurationContext, isolatedBuildService); - serviceInvoker.AddCommand(service, (codexContext, cloneResult, readFs: fs.Read, es), + serviceInvoker.AddCommand(service, (codexContext, cloneResult, fs, es), static async (s, col, state, c) => - await s.Index(state.codexContext, state.cloneResult, state.readFs, state.es, c) + await s.Index(state.codexContext, state.cloneResult, state.fs, state.es, c) ); return await serviceInvoker.InvokeAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs index 305d2a9995..6814fe6e41 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs @@ -94,7 +94,7 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex inner: plain, extraRoots: [gitRoot]); var configFile = fs.FileInfo.New(config.FullName); var codexConfig = CodexConfiguration.Load(configFile); - return (new CodexContext(codexConfig, configFile, collector, fs.Read, fs.Write, null, null), + return (new CodexContext(codexConfig, configFile, collector, fs), new IncrementalDeployService(logFactory, githubActionsService)); } } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index b5517d24a9..fd216f3319 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -50,7 +50,7 @@ public async Task UpdateRedirects( ?? Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "internal"; - var service = new DeployUpdateRedirectsService(logFactory, fs.Read); + var service = new DeployUpdateRedirectsService(logFactory, fs); serviceInvoker.AddCommand(service, (environment: resolvedEnvironment, redirectsFile, kvsNamePrefix: "codex", defaultRedirectsFile: ".artifacts/codex/docs/redirects.json"), static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile?.FullName, state.kvsNamePrefix, state.defaultRedirectsFile, c) ); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 49aa7dc274..4c777db9ec 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -30,7 +30,7 @@ public PublicOnlyAssemblerConfigurationTests() var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem, configurationFileProvider: configurationFileProvider); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs, assembleFs.Write, CheckoutDirectory.FullName, null); + Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs, CheckoutDirectory.FullName, null); } [Fact] @@ -67,7 +67,7 @@ public AssemblerConfigurationTests(DocumentationFixture fixture, ITestOutputHelp var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs2 = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs2, assembleFs2.Write, CheckoutDirectory.FullName, null); + Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs2, CheckoutDirectory.FullName, null); } [Fact] diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs index 92622bcef9..096528a084 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs @@ -47,7 +47,7 @@ public async Task TestPlan() var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs, assembleFs.Write, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); A.CallTo(() => mockS3Client.ListObjectsV2Async(A._, A._)) .Returns(new ListObjectsV2Response { @@ -189,7 +189,7 @@ bool valid var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs2 = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs2, assembleFs2.Write, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs2, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); var s3Objects = new List(); foreach (var i in Enumerable.Range(0, remoteFiles)) @@ -240,7 +240,7 @@ public async Task TestApply() var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var checkoutDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); var assembleFs3 = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs3, assembleFs3.Write, null, checkoutDirectory); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs3); var plan = new SyncPlan { RemoteListingCompleted = true, diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs index 43e9b179fa..b48d0f139c 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs @@ -48,7 +48,7 @@ public async Task AssemblerRoundTrip() var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new DiagnosticsCollector([]); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); - var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs, assembleFs.Write, null, outputDir); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs, null, outputDir); await RunRoundTrip(fs, s3, xfer, gh, svc, context, outputDir); } @@ -64,7 +64,7 @@ public async Task CodexRoundTrip() // so we can point to any path without adding it to the mock FS. var codexConfig = new CodexConfiguration { Environment = "dev" }; var configFile = fs.FileInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); - var context = new CodexContext(codexConfig, configFile, collector, codexFs, codexFs.Write, null, outputDir); + var context = new CodexContext(codexConfig, configFile, collector, codexFs, null, outputDir); await RunRoundTrip(fs, s3, xfer, gh, svc, context, outputDir); } @@ -228,7 +228,7 @@ public async Task ExcludedRemoteObjectsAreNotDeleted() var codexFs2 = CheckoutsFileSystem.FromWorkingDirectory(fs); var codexConfig = new CodexConfiguration { Environment = "dev" }; var configFile = fs.FileInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); - var context = new CodexContext(codexConfig, configFile, collector, codexFs2, codexFs2.Write, null, outputDir); + var context = new CodexContext(codexConfig, configFile, collector, codexFs2, null, outputDir); var planPath = Path.Join(outputDir, "sync-plan.json"); var planOk = await svc.Plan( diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs index fcbec4ae83..6b0050e00b 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs @@ -48,7 +48,7 @@ public async Task AssertRealNavigation() var fs = new FileSystem(); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, - assembleFs, assembleFs.Write, null, null); + assembleFs); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs index 87c05c7d48..61f5c36780 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs @@ -48,7 +48,7 @@ public async Task AssertRealNavigation() var fs = new FileSystem(); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, - assembleFs, assembleFs.Write, null, null); + assembleFs); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs index e06add9746..3afa2bfdc1 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs @@ -45,7 +45,7 @@ public SiteNavigationTests(DocumentationFixture fixture, ITestOutputHelper outpu var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs, assembleFs.Write, CheckoutDirectory.FullName, null); + Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs, CheckoutDirectory.FullName, null); } private Checkout CreateCheckout(IFileSystem fs, Repository repository) @@ -99,7 +99,7 @@ public async Task ReadAllPathPrefixes() var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs2 = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs2, assembleFs2.Write, null, null); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs2); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; var siteNavigationFile = SiteNavigationFile.Deserialize(await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken)); @@ -192,7 +192,7 @@ public async Task UriResolving() var configurationContext = TestHelpers.CreateConfigurationContext(fs); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs3 = CheckoutsFileSystem.FromWorkingDirectory(fs); - var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, assembleFs3, assembleFs3.Write, null, null); + var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, assembleFs3); var repos = assembleContext.Configuration.AvailableRepositories .Where(kv => !kv.Value.Skip) .Select(kv => kv.Value) From 487385d3f62560ec21f3d224b83382b03e0072fd Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 12:48:01 +0200 Subject: [PATCH 18/29] Migrate changelog off FileSystemFactory; delete FileSystemFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileSystemFactory's last callers were all in the changelog stack. Each changelog service was defaulting to RealRead / RealWrite when no filesystem was injected — hiding the dependency and making the scope invisible in the signature. - ChangelogCommand._fileSystem: FileSystemFactory.RealRead → CheckoutsFileSystem.FromWorkingDirectory(); field narrows to CheckoutsFileSystem - EvaluatePr: RealReadForRunnerTemp(env) → inline CheckoutsFileSystem with RUNNER_TEMP as an extraRoots entry; eliminates the TypeInitializationException hazard of scope validation in a static init - PrepareArtifact: RealGitRootForPathWrite(null, outputDir) → CheckoutsFileSystem(...).Write scoped to the output directory - EvaluateArtifact: RealGitRootForPathWrite(null, metadata) → read-scoped CheckoutsFileSystem; the previous write scope was an inversion — EvaluateArtifact only reads the metadata file All 11 changelog service constructors now take a required fileSystem parameter (no ?? default). Constructor param order was adjusted in 6 services so the required fileSystem precedes the optional params. ~30 test call sites updated accordingly. FileSystemFactory.cs deleted. FileSystemFactoryTests.cs renamed to CheckoutsFileSystemTests.cs with the two factory-static tests removed. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../FileSystemFactory.cs | 133 ------------------ .../Bundling/ChangelogBundleAmendService.cs | 6 +- .../Bundling/ChangelogBundlingService.cs | 6 +- .../Bundling/ChangelogRemoveService.cs | 6 +- .../Bundling/PromotionReportParser.cs | 4 +- .../Creation/ChangelogCreationService.cs | 6 +- .../ChangelogArtifactEvaluationService.cs | 4 +- .../ChangelogPrEvaluationService.cs | 6 +- .../ChangelogPrepareArtifactService.cs | 6 +- .../GitHubReleaseChangelogService.cs | 8 +- .../Rendering/ChangelogRenderingService.cs | 6 +- .../Uploading/ChangelogUploadService.cs | 6 +- .../docs-builder/Commands/ChangelogCommand.cs | 36 +++-- .../Changelogs/BundleAmendTests.cs | 2 +- .../Changelogs/BundleCdnSourcingTests.cs | 16 +-- .../Changelogs/BundleChangelogsTests.cs | 10 +- .../Changelogs/BundleFilesFilterTests.cs | 6 +- .../Changelogs/BundlePlanTests.cs | 2 +- .../BundleProfileGitHubReleaseTests.cs | 2 +- .../Changelogs/BundleReleaseVersionTests.cs | 2 +- .../Changelogs/ChangelogRemoveTests.cs | 6 +- .../Changelogs/CloudProfileFixtureTests.cs | 2 +- .../Create/CreateChangelogTestBase.cs | 2 +- .../Changelogs/Create/ReleaseVersionTests.cs | 2 +- .../Changelogs/RemoveReleaseVersionTests.cs | 2 +- .../Render/RenderChangelogTestBase.cs | 2 +- .../Creation/CIEnrichmentTests.cs | 4 +- .../Creation/ChangelogCreationServiceTests.cs | 10 +- ...ryTests.cs => CheckoutsFileSystemTests.cs} | 42 ++---- 29 files changed, 100 insertions(+), 245 deletions(-) delete mode 100644 src/Elastic.Documentation.Tooling/FileSystemFactory.cs rename tests/Elastic.Documentation.Configuration.Tests/{FileSystemFactoryTests.cs => CheckoutsFileSystemTests.cs} (64%) diff --git a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs deleted file mode 100644 index cf318e21e6..0000000000 --- a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs +++ /dev/null @@ -1,133 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.IO.Abstractions; -using Elastic.Documentation.FileSystems; -using Nullean.ScopedFileSystem; -using DirectoryInfoExtensions = Elastic.Documentation.Extensions.IDirectoryInfoExtensions; - -// ReSharper disable once CheckNamespace — intentionally preserving the original namespace so consumers need no using changes -#pragma warning disable IDE0130 -namespace Elastic.Documentation.Configuration; - -public static class FileSystemFactory -{ - // Read options: workspace + app data, all confirmed hidden names allowed. - // Includes .git (GitCheckoutInformation reads it) and .artifacts/.doc.state - // (incremental build reads existing output state). - private static readonly ScopedFileSystemOptions WorkingDirectoryReadOptions = new( - [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } - }; - - // Write options: same scope roots but no .git — nothing in the build output - // pipeline should ever write into the git repository metadata. - // Temp is allowed because deploy operations (e.g. S3 sync) stage files there. - private static readonly ScopedFileSystemOptions WorkingDirectoryWriteOptions = new( - [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" }, - AllowedSpecialFolders = AllowedSpecialFolder.Temp - }; - - /// - /// A pre-allocated for reading workspace files. - /// Scoped to the working directory root and per-user app data; allows .git - /// (read by GitCheckoutInformation), .artifacts and .doc.state - /// (read for incremental build state). - /// - public static ScopedFileSystem RealRead { get; } = new(new FileSystem(), WorkingDirectoryReadOptions); - - /// - /// A pre-allocated for writing build output. - /// Same scope as but without .git access — - /// nothing in the output pipeline should write into git repository metadata. - /// - public static ScopedFileSystem RealWrite { get; } = new(new FileSystem(), WorkingDirectoryWriteOptions); - - // Builds write options that include AllowedSpecialFolders.Temp PLUS the inner FS's own - // GetTempPath() as an explicit root — but only when the inner FS is MockFileSystem. - // - // On non-Windows MockFileSystem hardcodes a Unix-ified path ("/temp/", derived from "C:\temp") - // instead of calling System.IO.Path.GetTempPath(). AllowedSpecialFolder.Temp uses the real - // GetTempPath() (e.g. "/tmp/" on Linux), so the two diverge and scope validation fails for any - // path created via mockFs.Path.GetTempPath(). - // - // Fix tracked upstream: https://github.com/TestableIO/System.IO.Abstractions/pull/1454 - // Once that ships and we update the package reference we can drop this workaround. - // - // We use ScopedFileSystem.InnerType (added in Nullean.ScopedFileSystem 0.4.0) to avoid a - // fragile string-based type check. - private static ScopedFileSystemOptions BuildWriteOptions(IFileSystem inner, params string[] roots) - { - var allRoots = roots.ToList(); - var innerType = inner is ScopedFileSystem sf ? sf.InnerType : inner.GetType(); - if (!OperatingSystem.IsWindows() && innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) - { - // Cover MockFileSystem's unixified hardcoded temp path - var innerTemp = inner.Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (!string.IsNullOrEmpty(innerTemp) && !allRoots.Contains(innerTemp, StringComparer.OrdinalIgnoreCase)) - allRoots.Add(innerTemp); - } - return new ScopedFileSystemOptions([.. allRoots]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" }, - AllowedSpecialFolders = AllowedSpecialFolder.Temp - }; - } - - /// - /// Creates a write scoped to the git root of - /// (and if it falls outside that root). - /// Falls back to when both are . - /// Use in commands that accept explicit --path and/or --output arguments. - /// - public static ScopedFileSystem RealGitRootForPathWrite(string? path, string? output = null) - { - if (path is null && output is null) - return RealWrite; - - var plain = new FileSystem(); - string gitRoot; - if (path is not null) - { - var startDir = plain.DirectoryInfo.New( - plain.Directory.Exists(path) ? path : plain.Path.GetDirectoryName(path) ?? path); - gitRoot = Paths.FindGitRoot(startDir)?.FullName ?? startDir.FullName; - } - else - gitRoot = Paths.WorkingDirectoryRoot.FullName; - - var roots = new List { gitRoot, Paths.ApplicationData.FullName }; - - if (output is not null) - { - var absOutput = Path.IsPathRooted(output) ? output : Path.GetFullPath(output); - if (!DirectoryInfoExtensions.IsSubPathOf(plain.DirectoryInfo.New(absOutput), plain.DirectoryInfo.New(gitRoot))) - roots.Add(absOutput); - } - - return new ScopedFileSystem(plain, BuildWriteOptions(plain, [.. roots])); - } - - /// - /// Creates a read for CI environments, - /// extending the default scope with RUNNER_TEMP when available. - /// Falls back to when RUNNER_TEMP is not set. - /// Use in CI commands that need to read temporary files staged in the GitHub Actions runner. - /// - public static ScopedFileSystem RealReadForRunnerTemp(IEnvironmentVariables? environmentVariables = null) - { - var runnerTemp = environmentVariables?.GetEnvironmentVariable("RUNNER_TEMP"); - if (string.IsNullOrWhiteSpace(runnerTemp)) - return RealRead; - - var plain = new FileSystem(); - return new CheckoutsFileSystem(plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), extraRoots: [runnerTemp]); - } -} diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index 4ea6feaa51..aaa9fc24e0 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -56,7 +56,7 @@ public record AmendBundleArguments /// public partial class ChangelogBundleAmendService( ILoggerFactory logFactory, - ScopedFileSystem? fileSystem = null, + ScopedFileSystem fileSystem, IConfigurationContext? configurationContext = null) : IService { /// @@ -65,9 +65,9 @@ public partial class ChangelogBundleAmendService( private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly IFileSystem _fileSystem = fileSystem; private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; [GeneratedRegex(@"\.amend-(\d+)\.ya?ml$", RegexOptions.IgnoreCase)] diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index af98b09916..c53acfc057 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -145,18 +145,18 @@ public record BundlePlanResult /// public partial class ChangelogBundlingService( ILoggerFactory logFactory, + ScopedFileSystem fileSystem, IConfigurationContext? configurationContext = null, - ScopedFileSystem? fileSystem = null, IGitHubReleaseService? releaseService = null, CdnChangelogEntryFetcher? entryFetcher = null) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ScopedFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly ScopedFileSystem _fileSystem = fileSystem; private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly CdnChangelogEntryFetcher _entryFetcher = entryFetcher ?? new CdnChangelogEntryFetcher(logFactory); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; // Defaults applied when sourcing CDN entries and the org/branch are not otherwise resolvable. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index d73b0089d2..43063e528f 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -60,16 +60,16 @@ public record ChangelogRemoveArguments /// public class ChangelogRemoveService( ILoggerFactory logFactory, + ScopedFileSystem fileSystem, IConfigurationContext? configurationContext = null, - ScopedFileSystem? fileSystem = null, IGitHubReleaseService? releaseService = null) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ScopedFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly ScopedFileSystem _fileSystem = fileSystem; private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; public async Task RemoveChangelogs(IDiagnosticsCollector collector, ChangelogRemoveArguments input, Cancel ctx) diff --git a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs index 12e9091ac6..f9032760bb 100644 --- a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs +++ b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs @@ -15,10 +15,10 @@ namespace Elastic.Changelog.Bundling; /// /// Parser for promotion report HTML files to extract PR lists /// -public partial class PromotionReportParser(ILoggerFactory logFactory, ScopedFileSystem? fileSystem = null) +public partial class PromotionReportParser(ILoggerFactory logFactory, ScopedFileSystem fileSystem) { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly IFileSystem _fileSystem = fileSystem; private static readonly string[] AllowedHosts = ["github.com", "buildkite.com"]; diff --git a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs index a0397fca24..7502fbc0e4 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs @@ -71,17 +71,17 @@ public record CreateChangelogArguments public class ChangelogCreationService( ILoggerFactory logFactory, IConfigurationContext configurationContext, +ScopedFileSystem fileSystem, IGitHubPrService? githubPrService = null, -ScopedFileSystem? fileSystem = null, IEnvironmentVariables? env = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead); + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); private readonly CreateChangelogArgumentsValidator _validator = new(configurationContext); private readonly PrInfoProcessor _prProcessor = new(githubPrService, logFactory.CreateLogger()); private readonly IssueInfoProcessor _issueProcessor = new(githubPrService, logFactory.CreateLogger()); - private readonly ChangelogFileWriter _fileWriter = new(fileSystem ?? FileSystemFactory.RealWrite, logFactory.CreateLogger()); + private readonly ChangelogFileWriter _fileWriter = new(fileSystem, logFactory.CreateLogger()); private readonly ProductInferService _productInferService = new( configurationContext.ProductsConfiguration); diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs index 012fee7fd8..de6d820784 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -20,11 +20,11 @@ public class ChangelogArtifactEvaluationService( ILoggerFactory logFactory, IGitHubPrService gitHubPrService, ICoreService coreService, - IFileSystem? fileSystem = null + IFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly IFileSystem _fileSystem = fileSystem; public async Task EvaluateArtifact(IDiagnosticsCollector collector, EvaluateArtifactArguments input, Cancel ctx) { diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index 2b1c78eda7..d8793d4510 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -24,12 +24,12 @@ public class ChangelogPrEvaluationService( IConfigurationContext configurationContext, IGitHubPrService gitHubPrService, ICoreService coreService, - ScopedFileSystem? fileSystem = null + ScopedFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; - private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead); + private readonly IFileSystem _fileSystem = fileSystem; + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrArguments input, Cancel ctx) { diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs index 9fbd1c8cff..e3f2a807b9 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs @@ -20,7 +20,7 @@ public class ChangelogPrepareArtifactService( ILoggerFactory logFactory, IConfigurationContext configurationContext, ICoreService coreService, - IFileSystem? fileSystem = null + IFileSystem fileSystem ) : IService { /// @@ -29,8 +29,8 @@ public class ChangelogPrepareArtifactService( private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); - private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? new FileSystem()); + private readonly IFileSystem _fileSystem = fileSystem; + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); public async Task PrepareArtifact(IDiagnosticsCollector collector, PrepareArtifactArguments input, Cancel ctx) { diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 1784f98dcb..12ae7a243e 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -80,9 +80,9 @@ public record CreateChangelogsFromReleaseArguments public class GitHubReleaseChangelogService( ILoggerFactory logFactory, IConfigurationContext configurationContext, + ScopedFileSystem fileSystem, IGitHubReleaseService? releaseService = null, IGitHubPrService? prService = null, - ScopedFileSystem? fileSystem = null, ChangelogBundlingService? bundlingService = null ) : IService { @@ -92,11 +92,11 @@ public class GitHubReleaseChangelogService( private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; - private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead); + private readonly IFileSystem _fileSystem = fileSystem; + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly IGitHubPrService _prService = prService ?? new GitHubPrService(logFactory); - private readonly ChangelogBundlingService _bundlingService = bundlingService ?? new ChangelogBundlingService(logFactory, configurationContext, fileSystem); + private readonly ChangelogBundlingService _bundlingService = bundlingService ?? new ChangelogBundlingService(logFactory, fileSystem, configurationContext); public async Task CreateChangelogsFromRelease( IDiagnosticsCollector collector, diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index cf83389001..3c8eeb1c3c 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -70,12 +70,12 @@ public enum ChangelogFileType /// public class ChangelogRenderingService( ILoggerFactory logFactory, - IConfigurationContext? configurationContext = null, - ScopedFileSystem? fileSystem = null + ScopedFileSystem fileSystem, + IConfigurationContext? configurationContext = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ScopedFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealWrite; + private readonly ScopedFileSystem _fileSystem = fileSystem; public async Task RenderChangelogs( IDiagnosticsCollector collector, diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index d6e2c76cfc..491ebcc850 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -61,15 +61,15 @@ public record ChangelogUploadArguments public class ChangelogUploadService( ILoggerFactory logFactory, + ScopedFileSystem fileSystem, IConfigurationContext? configurationContext = null, - ScopedFileSystem? fileSystem = null, IAmazonS3? s3Client = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly IFileSystem _fileSystem = fileSystem; private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadArguments args, Cancel ctx) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 09cf49d80c..6c6cc2e2a1 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -23,6 +23,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; @@ -47,7 +48,7 @@ IEnvironmentVariables environmentVariables [GeneratedRegex(@"^( *output_directory:\s*).+$", RegexOptions.Multiline)] private static partial Regex BundleOutputDirectoryRegex(); - private readonly IFileSystem _fileSystem = FileSystemFactory.RealRead; + private readonly CheckoutsFileSystem _fileSystem = CheckoutsFileSystem.FromWorkingDirectory(); private readonly ILogger _logger = logFactory.CreateLogger(); /// Create changelog.yml and the changelog/releases directory structure. /// @@ -356,7 +357,7 @@ public async Task Add( var repoArg = resolvedRepo.Contains('/') ? resolvedRepo : $"{resolvedOwner}/{resolvedRepo}"; IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory); IGitHubPrService prService = new GitHubPrService(logFactory); - var releaseChangelogService = new GitHubReleaseChangelogService(logFactory, configurationContext, releaseService, prService); + var releaseChangelogService = new GitHubReleaseChangelogService(logFactory, configurationContext, _fileSystem, releaseService, prService); var releaseInput = new CreateChangelogsFromReleaseArguments { @@ -376,7 +377,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c } IGitHubPrService githubPrService = new GitHubPrService(logFactory); - var service = new ChangelogCreationService(logFactory, configurationContext, githubPrService, env: SystemEnvironmentVariables.Instance); + var service = new ChangelogCreationService(logFactory, configurationContext, _fileSystem, githubPrService, env: SystemEnvironmentVariables.Instance); // Parse PRs: promotion report (--report), or comma-separated values and file paths (--prs) string[]? parsedPrs = null; @@ -387,7 +388,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c !reportSource.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) reportSource = NormalizePath(reportSource); - var reportParser = new PromotionReportParser(logFactory, null); + var reportParser = new PromotionReportParser(logFactory, _fileSystem); parsedPrs = await reportParser.ParseReportToPrUrlsAsync(collector, reportSource, ctx); if (parsedPrs == null) { @@ -608,7 +609,7 @@ public async Task Bundle( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogBundlingService(logFactory, configurationContext); + var service = new ChangelogBundlingService(logFactory, _fileSystem, configurationContext); var isProfileMode = !string.IsNullOrWhiteSpace(profile); @@ -974,7 +975,7 @@ public async Task Remove( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogRemoveService(logFactory, configurationContext); + var service = new ChangelogRemoveService(logFactory, _fileSystem, configurationContext); var isProfileMode = !string.IsNullOrWhiteSpace(profile); @@ -1190,7 +1191,7 @@ public async Task Render( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogRenderingService(logFactory, configurationContext); + var service = new ChangelogRenderingService(logFactory, _fileSystem, configurationContext); var allFeatureIds = ExpandCommaSeparated(hideFeatures); @@ -1263,7 +1264,7 @@ public async Task GhRelease( IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory); IGitHubPrService prService = new GitHubPrService(logFactory); - var service = new GitHubReleaseChangelogService(logFactory, configurationContext, releaseService, prService); + var service = new GitHubReleaseChangelogService(logFactory, configurationContext, _fileSystem, releaseService, prService); // Validate release date format if provided if (!string.IsNullOrWhiteSpace(releaseDate) && !DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out _)) @@ -1314,7 +1315,7 @@ public async Task BundleAmend( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogBundleAmendService(logFactory, configurationContext: configurationContext); + var service = new ChangelogBundleAmendService(logFactory, _fileSystem, configurationContext: configurationContext); var normalizedAddFiles = add != null ? ExpandCommaSeparated(add).Select(NormalizePath).ToList() @@ -1391,7 +1392,10 @@ public async Task EvaluatePr( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var fileSystem = FileSystemFactory.RealReadForRunnerTemp(environmentVariables); + var runnerTemp = environmentVariables.GetEnvironmentVariable("RUNNER_TEMP"); + var fileSystem = new CheckoutsFileSystem( + new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + extraRoots: string.IsNullOrWhiteSpace(runnerTemp) ? null : [runnerTemp]); IGitHubPrService prService = new GitHubPrService(logFactory); var service = new ChangelogPrEvaluationService(logFactory, configurationContext, prService, githubActionsService, fileSystem); @@ -1481,7 +1485,10 @@ public async Task PrepareArtifact( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealGitRootForPathWrite(null, outputDir); + var physical = new FileSystem(); + var fs = new CheckoutsFileSystem( + physical.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + output: string.IsNullOrWhiteSpace(outputDir) ? null : physical.DirectoryInfo.New(outputDir)).Write; var service = new ChangelogPrepareArtifactService(logFactory, configurationContext, githubActionsService, fs); var args = new PrepareArtifactArguments @@ -1530,7 +1537,10 @@ public async Task EvaluateArtifact( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealGitRootForPathWrite(null, metadata); + var metadataDir = Path.GetDirectoryName(metadata); + var fs = new CheckoutsFileSystem( + new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + extraRoots: string.IsNullOrWhiteSpace(metadataDir) ? null : [metadataDir]); IGitHubPrService prService = new GitHubPrService(logFactory); var service = new ChangelogArtifactEvaluationService(logFactory, prService, githubActionsService, fs); @@ -1657,7 +1667,7 @@ public async Task Upload( var (resolvedRepo, resolvedOwner, resolvedBranch) = await ResolveUploadRepoOwnerBranch(repo, owner, branch, resolvedConfig, resolvedDirectory, ctx); await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogUploadService(logFactory, configurationContext); + var service = new ChangelogUploadService(logFactory, _fileSystem, configurationContext); var args = new ChangelogUploadArguments { ArtifactType = parsedArtifactType, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs index 14f8c4ec8c..0faa715f48 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs @@ -20,7 +20,7 @@ public class BundleAmendTests : ChangelogTestBase public BundleAmendTests(ITestOutputHelper output) : base(output) { Service = new(LoggerFactory, FileSystem); - BundleService = new(LoggerFactory, null, FileSystem); + BundleService = new(LoggerFactory, FileSystem); _changelogDir = CreateChangelogDir(); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs index 49cacb9969..63b770eae0 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs @@ -74,7 +74,7 @@ public async Task OptionMode_RepoResolvable_SourcesAllEntriesFromRepoPoolOnCdn() // Under the artifact-root layout the CDN entry pool is keyed by the authoring repo, not the // target product. A resolvable repo (here via --repo) is what enables CDN sourcing. var handler = RegistryHandler(); - var service = new ChangelogBundlingService(LoggerFactory, null, FileSystem, null, Fetcher(Output, handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler)); var output = OutputPath(); var input = new BundleChangelogsArguments @@ -103,7 +103,7 @@ public async Task OptionMode_OwnerAndBranchOverride_SourcesFromThatPoolOnCdn() { // Explicit owner/branch select a specific pool; the branch is stored verbatim (dots kept). var handler = RegistryHandler(); - var service = new ChangelogBundlingService(LoggerFactory, null, FileSystem, null, Fetcher(Output, handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler)); var output = OutputPath(); var input = new BundleChangelogsArguments @@ -128,7 +128,7 @@ public async Task OptionMode_OwnerFromCombinedRepo_SourcesFromThatPool() // When --repo is given in owner/repo form and no explicit owner is set, the owner segment must be // taken from the repo prefix (not defaulted to elastic), so the CDN pool path stays correct. var handler = RegistryHandler(); - var service = new ChangelogBundlingService(LoggerFactory, null, FileSystem, null, Fetcher(Output, handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler)); var output = OutputPath(); var input = new BundleChangelogsArguments @@ -166,7 +166,7 @@ await FileSystem.File.WriteAllTextAsync( await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); var handler = RegistryHandler(); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, Fetcher(Output, handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, Fetcher(Output, handler)); var output = OutputPath(); var input = new BundleChangelogsArguments { Config = configPath, Output = output, All = true }; @@ -202,7 +202,7 @@ await FileSystem.File.WriteAllTextAsync( await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); var handler = RegistryHandler(); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, Fetcher(Output, handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, Fetcher(Output, handler)); var output = OutputPath(); var input = new BundleChangelogsArguments @@ -226,7 +226,7 @@ public async Task RegistryFailure_FailsBundle() { var fetcher = new CdnChangelogEntryFetcher(new TestLoggerFactory(Output), new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)), sleep: (_, _) => Task.CompletedTask); - var service = new ChangelogBundlingService(LoggerFactory, null, FileSystem, null, fetcher); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, fetcher); var input = new BundleChangelogsArguments { @@ -256,7 +256,7 @@ public async Task EntryMissingAfterRetries_FailsBundle() return new HttpResponseMessage(HttpStatusCode.NotFound); // 2-bravo.yaml never propagates }); var fetcher = new CdnChangelogEntryFetcher(new TestLoggerFactory(Output), handler, maxAttempts: 2, sleep: (_, _) => Task.CompletedTask); - var service = new ChangelogBundlingService(LoggerFactory, null, FileSystem, null, fetcher); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, fetcher); var input = new BundleChangelogsArguments { @@ -301,7 +301,7 @@ public async Task ProfileGitHubRelease_ScopesByOutputProductsAndFiltersByRelease A.CallTo(() => releaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.3.0", TestContext.Current.CancellationToken)) .Returns(new GitHubReleaseInfo { TagName = "v9.3.0", Name = "9.3.0", Body = releaseBody }); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, releaseService, Fetcher()); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, releaseService, Fetcher()); var input = new BundleChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 343884f9bb..313acf2a73 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -21,8 +21,8 @@ public class BundleChangelogsTests : ChangelogTestBase public BundleChangelogsTests(ITestOutputHelper output) : base(output) { - Service = new(LoggerFactory, null, FileSystem); - ServiceWithConfig = new(LoggerFactory, ConfigurationContext, FileSystem); + Service = new(LoggerFactory, FileSystem); + ServiceWithConfig = new(LoggerFactory, FileSystem, ConfigurationContext); _changelogDir = CreateChangelogDir(); } @@ -3263,7 +3263,7 @@ public async Task BundleChangelogs_WithProfileMode_MissingConfig_ReturnsErrorWit currentDirectory: "/empty-project" ); cwdFs.Directory.CreateDirectory("/empty-project"); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, CheckoutsFileSystem.FromWorkingDirectory(cwdFs)); + var service = new ChangelogBundlingService(LoggerFactory, CheckoutsFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new BundleChangelogsArguments { @@ -3324,7 +3324,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSucce """; await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, CheckoutsFileSystem.FromWorkingDirectory(cwdFs)); + var service = new ChangelogBundlingService(LoggerFactory, CheckoutsFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new BundleChangelogsArguments { @@ -3385,7 +3385,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce """; await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, CheckoutsFileSystem.FromWorkingDirectory(cwdFs)); + var service = new ChangelogBundlingService(LoggerFactory, CheckoutsFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new BundleChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs index 590def67ba..d42198524c 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs @@ -51,7 +51,7 @@ public class BundleFilesFilterTests : ChangelogTestBase public BundleFilesFilterTests(ITestOutputHelper output) : base(output) { - ServiceWithConfig = new(LoggerFactory, ConfigurationContext, FileSystem); + ServiceWithConfig = new(LoggerFactory, FileSystem, ConfigurationContext); _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); } @@ -277,7 +277,7 @@ public async Task Bundle_WithFiles_ForcesLocalEvenWhenRepoResolves() var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); var fetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, fetcher); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, fetcher); var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments @@ -312,7 +312,7 @@ public async Task Bundle_WithForceLocal_SourcesLocalDespiteResolvableRepo() var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); var fetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, fetcher); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, fetcher); var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs index 75070688ab..bab8772ab3 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs @@ -15,7 +15,7 @@ public class BundlePlanTests : ChangelogTestBase private ChangelogBundlingService Service { get; } public BundlePlanTests(ITestOutputHelper output) : base(output) => - Service = new(LoggerFactory, ConfigurationContext, FileSystem); + Service = new(LoggerFactory, FileSystem, ConfigurationContext); private async Task CreateConfigAsync(string configContent) { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs index ebf213c17e..72347cffa2 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs @@ -23,7 +23,7 @@ public class BundleProfileGitHubReleaseTests : ChangelogTestBase public BundleProfileGitHubReleaseTests(ITestOutputHelper output) : base(output) { _mockReleaseService = A.Fake(); - _service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, _mockReleaseService); + _service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, _mockReleaseService); _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs index e130c3cc5e..557b5423b2 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs @@ -26,7 +26,7 @@ public class BundleReleaseVersionTests : ChangelogTestBase public BundleReleaseVersionTests(ITestOutputHelper output) : base(output) { - _bundlingService = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem); + _bundlingService = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext); _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs index a5f98e737e..b7a2155078 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -72,8 +72,8 @@ public class ChangelogRemoveTests : ChangelogTestBase public ChangelogRemoveTests(ITestOutputHelper output) : base(output) { - Service = new ChangelogRemoveService(LoggerFactory, null, FileSystem); - ServiceWithConfig = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, FileSystem); + Service = new ChangelogRemoveService(LoggerFactory, FileSystem); + ServiceWithConfig = new ChangelogRemoveService(LoggerFactory, FileSystem, ConfigurationContext); _changelogDir = CreateChangelogDir(); } @@ -458,7 +458,7 @@ public async Task Remove_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() currentDirectory: "/empty-project" ); cwdFs.Directory.CreateDirectory("/empty-project"); - var service = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, CheckoutsFileSystem.FromWorkingDirectory(cwdFs)); + var service = new ChangelogRemoveService(LoggerFactory, CheckoutsFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new ChangelogRemoveArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs index a31bc4b37c..32294e20b7 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs @@ -133,7 +133,7 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); var handler = RepoPoolHandler(); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, Fetcher(handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, Fetcher(handler)); var input = new BundleChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs index 1c485eedfb..b679f379df 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs @@ -15,7 +15,7 @@ public abstract class CreateChangelogTestBase(ITestOutputHelper output) : Change protected IGitHubPrService MockGitHubService { get; } = A.Fake(); protected ChangelogCreationService CreateService(IEnvironmentVariables? env = null) => - new(LoggerFactory, ConfigurationContext, MockGitHubService, FileSystem, env); + new(LoggerFactory, ConfigurationContext, FileSystem, MockGitHubService, env); protected async Task CreateConfigDirectory(string configContent) { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs index aa50df84f7..6d144f0bf1 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs @@ -21,7 +21,7 @@ public class ReleaseVersionTests(ITestOutputHelper output) : ChangelogTestBase(o private readonly IGitHubPrService _mockPrService = A.Fake(); private GitHubReleaseChangelogService CreateService() => - new(LoggerFactory, ConfigurationContext, _mockReleaseService, _mockPrService, FileSystem); + new(LoggerFactory, ConfigurationContext, FileSystem, _mockReleaseService, _mockPrService); private string CreateOutputDirectory() => FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs index f64aeebf19..f0dcdefd2d 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs @@ -25,7 +25,7 @@ public class RemoveReleaseVersionTests : ChangelogTestBase public RemoveReleaseVersionTests(ITestOutputHelper output) : base(output) { - _removeService = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, FileSystem); + _removeService = new ChangelogRemoveService(LoggerFactory, FileSystem, ConfigurationContext); _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/RenderChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/RenderChangelogTestBase.cs index 684b0d9a92..4d9b4a93b6 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/RenderChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/RenderChangelogTestBase.cs @@ -11,5 +11,5 @@ public abstract class RenderChangelogTestBase : ChangelogTestBase protected ChangelogRenderingService Service { get; } protected RenderChangelogTestBase(ITestOutputHelper output) : base(output) => - Service = new ChangelogRenderingService(LoggerFactory, ConfigurationContext, FileSystem); + Service = new ChangelogRenderingService(LoggerFactory, FileSystem, ConfigurationContext); } diff --git a/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs b/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs index ab38e30eb0..e207d26e98 100644 --- a/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs @@ -45,7 +45,7 @@ private static IEnvironmentVariables FakeLocalEnv() } private ChangelogCreationService CreateServiceWithEnv(IEnvironmentVariables env) => - new(LoggerFactory, ConfigurationContext, env: env); + new(LoggerFactory, ConfigurationContext, FileSystem, env: env); [Fact] public void EnrichFromCI_NotInCI_ReturnsUnchanged() @@ -167,7 +167,7 @@ public void EnrichFromCI_InCI_TitleOnly_EnrichesWithoutPr() [Fact] public void EnrichFromCI_NullEnv_ReturnsUnchanged() { - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem); var input = DefaultInput() with { Title = "original" }; var result = service.EnrichFromCI(input); diff --git a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs index 0cbf160621..c116842877 100644 --- a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs @@ -84,7 +84,7 @@ public async Task CreateChangelog_CIWithProducts_SkipsPrFetchAndSucceeds() products: "cloud-hosted, cloud-serverless" ); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, FileSystem, env); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, env); var input = new CreateChangelogArguments { Products = [], @@ -127,7 +127,7 @@ public async Task CreateChangelog_CIWithoutProducts_FallsBackToPrFetchForProduct repo: "cloud" ); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, FileSystem, env); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, env); var input = new CreateChangelogArguments { Products = [], @@ -170,7 +170,7 @@ public async Task CreateChangelog_CIWithoutProducts_NoPrProductLabels_FailsWithP repo: "cloud" ); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, FileSystem, env); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, env); var input = new CreateChangelogArguments { Products = [], @@ -218,7 +218,7 @@ public async Task CreateChangelog_TempOutputDirectory_Succeeds() products: "elasticsearch" ); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, writeFs, env); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, writeFs, _mockGitHub, env); var input = new CreateChangelogArguments { Products = [], @@ -242,7 +242,7 @@ public async Task CreateChangelog_OutputDoesNotContainBom() var tempOutput = Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(tempOutput); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, FileSystem, null); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, null); var input = new CreateChangelogArguments { Title = "Test BOM handling", diff --git a/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CheckoutsFileSystemTests.cs similarity index 64% rename from tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs rename to tests/Elastic.Documentation.Configuration.Tests/CheckoutsFileSystemTests.cs index 1d1316e126..a3da9685d6 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CheckoutsFileSystemTests.cs @@ -9,17 +9,10 @@ namespace Elastic.Documentation.Configuration.Tests; -sealed file class StubEnv(string? runnerTemp) : IEnvironmentVariables -{ - public string? GetEnvironmentVariable(string name) => - name == "RUNNER_TEMP" ? runnerTemp : null; - public bool IsRunningOnCI => runnerTemp is not null; -} - -public class FileSystemFactoryTests +public class CheckoutsFileSystemTests { [Fact] - public void CheckoutsFileSystem_NestedExtensionRoot_DoesNotThrow() + public void NestedExtensionRoot_DoesNotThrow() { var workingRoot = Paths.WorkingDirectoryRoot.FullName; var nestedConfigDir = Path.Join(workingRoot, "environments", "internal"); @@ -37,7 +30,7 @@ public void CheckoutsFileSystem_NestedExtensionRoot_DoesNotThrow() } [Fact] - public void CheckoutsFileSystem_ExternalExtensionRoot_AllowsReadingExternalConfig() + public void ExternalExtensionRoot_AllowsReadingExternalConfig() { var workingRoot = Paths.WorkingDirectoryRoot.FullName; var externalRoot = Path.Join(Path.GetTempPath(), $"external-codex-{Guid.NewGuid():N}"); @@ -53,7 +46,7 @@ public void CheckoutsFileSystem_ExternalExtensionRoot_AllowsReadingExternalConfi } [Fact] - public void CheckoutsFileSystem_AncestorExtensionRoot_DoesNotThrow() + public void AncestorExtensionRoot_DoesNotThrow() { // An ancestor of the working root would produce overlapping roots, the same class of // crash fixed in a96ef869 / 3c5f9703 for Codex nested paths. @@ -65,41 +58,26 @@ public void CheckoutsFileSystem_AncestorExtensionRoot_DoesNotThrow() act.Should().NotThrow(); var scoped = act(); - // The scoped filesystem must still allow reads within the working root. var fileInWorkingRoot = Path.Join(workingRoot, "some.yml"); mockFs.AddFile(fileInWorkingRoot, new MockFileData("test")); scoped.File.Exists(fileInWorkingRoot).Should().BeTrue(); } [Fact] - public void RealReadForRunnerTemp_RunnerTempUnset_FallsBackToRealRead() - { - var env = new StubEnv(runnerTemp: null); - - var result = FileSystemFactory.RealReadForRunnerTemp(env); - - result.Should().BeSameAs(FileSystemFactory.RealRead); - } - - [Fact] - public void RealReadForRunnerTemp_RunnerTempSibling_AllowsReadingStagedFile() + public void ExtraRunnerTempRoot_AllowsReadingStagedFile() { - // Simulate the GitHub Actions hosted runner layout: RUNNER_TEMP and the - // checkout root are sibling directories. A file staged by the action in - // RUNNER_TEMP must be readable, which the plain RealRead scope denies. + // Simulate the GitHub Actions hosted runner layout: RUNNER_TEMP and the checkout root + // are sibling directories. A file staged in RUNNER_TEMP must be readable, which a + // plain working-dir scope denies. var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); - var env = new StubEnv(runnerTemp: tempDir); var stagedFile = Path.Join(tempDir, "changelog-pr-body.md"); var mockFs = new MockFileSystem(new Dictionary { { stagedFile, new MockFileData("Release Notes: fix memory leak") } }, new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - var scoped = FileSystemFactory.RealReadForRunnerTemp(env); - // We call the overload that accepts inner FS, reusing the factory helper - // that RealReadForRunnerTemp delegates to. - var scopedMock = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: mockFs, extraRoots: [tempDir]); + var scoped = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: mockFs, extraRoots: [tempDir]); - scopedMock.File.Exists(stagedFile).Should().BeTrue(); + scoped.File.Exists(stagedFile).Should().BeTrue(); } } From 158a46484ce7cc8773e1c7dadb6b1f94793b4384 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 13:33:16 +0200 Subject: [PATCH 19/29] Add filesystem marker interfaces; create RunnerTempFileSystem Add IAppDataFileSystem, ICheckoutsFileSystem, IChangelogFileSystem, IDocumentationFileSystem, and IRunnerTempFileSystem marker interfaces. Each concrete filesystem type now declares its capability via one of these interfaces, making the scope of each dependency explicit in every signature. ConfigurationFileProvider narrows from IFileSystem to IAppDataFileSystem. All changelog services narrow from ScopedFileSystem/IFileSystem to IChangelogFileSystem. Evaluation commands get a dedicated RunnerTempFileSystem with per-command factory methods (ForEvaluatePr, ForEvaluateArtifact, ForPrepareArtifact) replacing the inline CheckoutsFileSystem constructions. Two side-fixes: ChangelogFileSystem.FromWorkingDirectory now reads the current directory from the supplied filesystem rather than the static Paths value, so MockFileSystem with a custom CWD works correctly; ConfigurationFileSystem unwraps a ScopedFileSystem inner rather than throwing (ScopedFileSystem cannot nest). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../ConfigurationFileProvider.cs | 4 +- .../FileSystems/ApplicationDataFileSystem.cs | 16 ++-- .../FileSystems/ChangelogFileSystem.cs | 38 ++++++++ .../FileSystems/CheckoutsFileSystem.cs | 2 +- .../FileSystems/ConfigurationFileSystem.cs | 23 +++-- .../FileSystems/DocumentationFileSystem.cs | 2 +- .../FileSystems/IAppDataFileSystem.cs | 15 ++++ .../FileSystems/IChangelogFileSystem.cs | 15 ++++ .../FileSystems/ICheckoutsFileSystem.cs | 13 +++ .../FileSystems/IDocumentationFileSystem.cs | 16 ++++ .../FileSystems/IRunnerTempFileSystem.cs | 15 ++++ .../FileSystems/RunnerTempFileSystem.cs | 88 +++++++++++++++++++ .../Bundling/ChangelogBundleAmendService.cs | 6 +- .../Bundling/ChangelogBundlingService.cs | 6 +- .../Bundling/ChangelogRemoveService.cs | 6 +- .../Bundling/ProfileFilterResolver.cs | 10 +-- .../Bundling/PromotionReportParser.cs | 6 +- .../Creation/ChangelogCreationService.cs | 4 +- .../ChangelogArtifactEvaluationService.cs | 6 +- .../Evaluation/ChangelogPrBodyReader.cs | 3 +- .../ChangelogPrEvaluationService.cs | 6 +- .../ChangelogPrepareArtifactService.cs | 6 +- .../GitHubReleaseChangelogService.cs | 6 +- .../Rendering/ChangelogRenderer.cs | 4 +- .../Rendering/ChangelogRenderingService.cs | 6 +- .../BreakingChangesMarkdownRenderer.cs | 4 +- .../Markdown/ChangelogGfmRenderer.cs | 4 +- .../Markdown/ChangelogMarkdownRenderer.cs | 4 +- .../Markdown/DeprecationsMarkdownRenderer.cs | 4 +- .../Markdown/HighlightsMarkdownRenderer.cs | 4 +- .../Markdown/IndexMarkdownRenderer.cs | 4 +- .../Markdown/KnownIssuesMarkdownRenderer.cs | 4 +- .../Markdown/MarkdownRendererBase.cs | 6 +- .../Uploading/ChangelogUploadService.cs | 6 +- .../docs-builder/Commands/ChangelogCommand.cs | 17 +--- .../AssemblerConfigurationTests.cs | 2 +- .../TestHelpers.cs | 3 +- .../SearchRelevanceTests.cs | 3 +- .../Elastic.ApiExplorer.Tests/TestHelpers.cs | 3 +- .../Changelogs/BundleChangelogsTests.cs | 6 +- .../Changelogs/ChangelogRemoveTests.cs | 2 +- .../Changelogs/ChangelogTestBase.cs | 15 +++- .../Creation/ChangelogCreationServiceTests.cs | 26 ++---- ...ChangelogArtifactEvaluationServiceTests.cs | 2 +- .../Evaluation/ChangelogPrBodyReaderTests.cs | 15 +++- .../ChangelogPrEvaluationServiceTests.cs | 2 +- .../ChangelogPrepareArtifactServiceTests.cs | 2 +- .../Uploading/ChangelogUploadServiceTests.cs | 4 +- .../TestHelpers.cs | 3 +- .../CreateNavigationFileTests.cs | 3 +- .../ProductFeaturesTests.cs | 4 +- .../VersionInferenceTests.cs | 5 +- tests/Elastic.Markdown.Tests/TestHelpers.cs | 2 +- tests/authoring/Framework/Setup.fs | 2 +- 54 files changed, 349 insertions(+), 134 deletions(-) create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/IAppDataFileSystem.cs create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/IChangelogFileSystem.cs create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/ICheckoutsFileSystem.cs create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/IDocumentationFileSystem.cs create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/IRunnerTempFileSystem.cs create mode 100644 src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index 1f9486d58e..7ccf52d046 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -18,7 +18,7 @@ namespace Elastic.Documentation.Configuration; public partial class ConfigurationFileProvider { - private readonly IFileSystem _fileSystem; + private readonly IAppDataFileSystem _fileSystem; private readonly string _assemblyName; private readonly ILogger _logger; @@ -38,7 +38,7 @@ public partial class ConfigurationFileProvider public ConfigurationFileProvider( ILoggerFactory logFactory, - IFileSystem fileSystem, + IAppDataFileSystem fileSystem, bool skipPrivateRepositories = false, ConfigurationSource? configurationSource = null ) diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs index dbf5b38a1a..c52651b934 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs @@ -13,12 +13,14 @@ namespace Elastic.Documentation.FileSystems; /// Use for components that access caches or state and have no need for workspace files /// (e.g. CrossLinkFetcher, CheckForUpdatesFilter, GitLinkIndexReader). /// -public class ApplicationDataFileSystem(IFileSystem? inner = null) : ScopedFileSystem( - inner ?? new FileSystem(), - new ScopedFileSystemOptions([Paths.ApplicationData.FullName]) - { - // .git needed for codex-link-index clone directory inside ApplicationData - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } - }) +public class ApplicationDataFileSystem(IFileSystem? inner = null) + : ScopedFileSystem( + inner ?? new FileSystem(), + new ScopedFileSystemOptions([Paths.ApplicationData.FullName]) + { + // .git needed for codex-link-index clone directory inside ApplicationData + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }), + IAppDataFileSystem { } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs new file mode 100644 index 0000000000..266d91f065 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs @@ -0,0 +1,38 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Scope for changelog commands: the git root of the target repository. +/// Allows reading .git metadata (remote URL, branch); does not include +/// AppData or build artifacts — changelog operates only within the repo working tree. +/// +public class ChangelogFileSystem(IDirectoryInfo root, IFileSystem? inner = null) + : ScopedFileSystem(inner ?? Physical, new ScopedFileSystemOptions([root.FullName]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }), + IChangelogFileSystem +{ + private static readonly FileSystem Physical = new(); + + /// + /// Creates a scope anchored at the git root of the current working directory. + /// Falls back to the working directory itself when no .git root is found — + /// changelog init is designed to run before a git repository exists. + /// + public static ChangelogFileSystem FromWorkingDirectory(IFileSystem? inner = null) + { + var fs = inner ?? Physical; + var workingRoot = fs.DirectoryInfo.New(fs.Directory.GetCurrentDirectory()); + var gitRoot = Paths.FindGitRoot(workingRoot) ?? workingRoot; + return new ChangelogFileSystem(gitRoot, inner); + } +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs index d19c26c15c..448ee8cd9b 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs @@ -17,7 +17,7 @@ namespace Elastic.Documentation.FileSystems; /// Use when you have a single documentation set with a docset anchor. /// /// -public class CheckoutsFileSystem : ScopedFileSystem +public class CheckoutsFileSystem : ScopedFileSystem, ICheckoutsFileSystem { private static readonly FileSystem Physical = new(); diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs index 45e1bb0981..01d36090b5 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs @@ -13,14 +13,19 @@ namespace Elastic.Documentation.FileSystems; /// per-user application data. Used by ConfigurationFileProvider, which reads /// config/*.yml and writes runtime artefacts under AppData/config-runtime. /// -public class ConfigurationFileSystem(IFileSystem? inner = null) : ScopedFileSystem( - inner ?? new FileSystem(), - new ScopedFileSystemOptions([ - System.IO.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config"), - Paths.ApplicationData.FullName - ]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } - }) +public class ConfigurationFileSystem(IFileSystem? inner = null) + : ScopedFileSystem( + // ScopedFileSystem cannot wrap another ScopedFileSystem. When a ScopedFileSystem is + // supplied (e.g. a DocumentationFileSystem from a test context) use the physical FS instead, + // since config files live outside a docset scope anyway. + inner is ScopedFileSystem ? new FileSystem() : (inner ?? new FileSystem()), + new ScopedFileSystemOptions([ + System.IO.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config"), + Paths.ApplicationData.FullName + ]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }), + IAppDataFileSystem { } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs index c169eb2c5e..2e4fa1fea7 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs @@ -19,7 +19,7 @@ namespace Elastic.Documentation.FileSystems; /// scopes before the final scope is built. /// /// -public class DocumentationFileSystem : ScopedFileSystem +public class DocumentationFileSystem : ScopedFileSystem, IDocumentationFileSystem { private static readonly FileSystem Physical = new(); diff --git a/src/Elastic.Documentation.Tooling/FileSystems/IAppDataFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/IAppDataFileSystem.cs new file mode 100644 index 0000000000..0395f73a7c --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/IAppDataFileSystem.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Marker interface for filesystems that include the application data directory +/// () in their scope. +/// Services that read or write AppData (caches, link indices, config-runtime state) should +/// declare this interface rather than the bare . +/// +public interface IAppDataFileSystem : IFileSystem; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/IChangelogFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/IChangelogFileSystem.cs new file mode 100644 index 0000000000..d006087101 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/IChangelogFileSystem.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Marker interface for the changelog scope: the git root of the target repository. +/// Changelog services declare this rather than bare so that the +/// compiler enforces that only a properly-scoped filesystem (one that can reach the +/// changelog YAML files and read .git metadata) is wired in. +/// +public interface IChangelogFileSystem : IFileSystem; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ICheckoutsFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ICheckoutsFileSystem.cs new file mode 100644 index 0000000000..d7c6c4369d --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/ICheckoutsFileSystem.cs @@ -0,0 +1,13 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.FileSystems; + +/// +/// Marker interface for the checkout-tree scope: a working-directory root paired with the +/// application data directory. The checkouts filesystem is the read/write aggregate used by +/// assembler and codex commands that operate across many repository clones. +/// Extends because the checkouts scope always includes AppData. +/// +public interface ICheckoutsFileSystem : IAppDataFileSystem; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/IDocumentationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/IDocumentationFileSystem.cs new file mode 100644 index 0000000000..9f24ab94fc --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/IDocumentationFileSystem.cs @@ -0,0 +1,16 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Marker interface for the documentation-set scope: a single checked-out repository +/// anchored to a docset.yml. Only implements +/// this; declaring it on parameters ensures the compiler rejects an assembler-scope +/// or a changelog-scope +/// where a docset-anchored read scope is required. +/// +public interface IDocumentationFileSystem : IFileSystem; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/IRunnerTempFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/IRunnerTempFileSystem.cs new file mode 100644 index 0000000000..561f53905c --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/IRunnerTempFileSystem.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Marker interface for the CI runner scope: the working directory root plus one or more +/// runner-provided paths (RUNNER_TEMP, artifact output dirs, metadata paths). +/// Services that operate across the CI workspace without a fixed docset anchor declare this +/// rather than bare . +/// +public interface IRunnerTempFileSystem : IFileSystem; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs new file mode 100644 index 0000000000..5160871446 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs @@ -0,0 +1,88 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; +using static Elastic.Documentation.Extensions.IDirectoryInfoExtensions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Scope for CI evaluation commands: the working-directory root plus any runner-provided paths +/// (RUNNER_TEMP, artifact output dirs, staging dirs, metadata file locations). +/// +/// Used by evaluate-pr, evaluate-artifact, and prepare-artifact — +/// all three operate on paths vended by the CI environment, not a fixed docset or checkout. +/// Permits .git so changelog configuration can be located by the config loader. +/// +/// +public class RunnerTempFileSystem( + IDirectoryInfo workingRoot, + IEnumerable? ciPaths = null, + IFileSystem? inner = null) + : ScopedFileSystem(inner ?? Physical, BuildOptions(workingRoot, ciPaths)), + IRunnerTempFileSystem +{ + private static readonly FileSystem Physical = new(); + + private static ScopedFileSystemOptions BuildOptions(IDirectoryInfo workingRoot, IEnumerable? ciPaths) + { + var fs = workingRoot.FileSystem; + var roots = new List { workingRoot.FullName }; + + if (ciPaths is not null) + { + foreach (var path in ciPaths) + { + if (string.IsNullOrEmpty(path)) + continue; + // Drop descendants and ancestors of workingRoot to avoid disjointness violations. + var isDescendant = IsSubPath(path, workingRoot.FullName, fs); + var isAncestor = IsSubPath(workingRoot.FullName, path, fs); + var isDuplicate = roots.Contains(path, StringComparer.OrdinalIgnoreCase); + if (!isDescendant && !isAncestor && !isDuplicate) + roots.Add(path); + } + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }; + } + + public static RunnerTempFileSystem ForEvaluatePr(IEnvironmentVariables env, IFileSystem? inner = null) + { + var fs = inner ?? Physical; + var workingRoot = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var runnerTemp = env.GetEnvironmentVariable("RUNNER_TEMP"); + return new RunnerTempFileSystem(workingRoot, + ciPaths: string.IsNullOrWhiteSpace(runnerTemp) ? null : [runnerTemp], + inner: inner); + } + + public static RunnerTempFileSystem ForEvaluateArtifact(string metadataPath, IFileSystem? inner = null) + { + var fs = inner ?? Physical; + var workingRoot = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var metadataDir = System.IO.Path.GetDirectoryName(metadataPath); + return new RunnerTempFileSystem(workingRoot, + ciPaths: string.IsNullOrWhiteSpace(metadataDir) ? null : [metadataDir], + inner: inner); + } + + public static RunnerTempFileSystem ForPrepareArtifact(string? stagingDir, string? outputDir, IFileSystem? inner = null) + { + var fs = inner ?? Physical; + var workingRoot = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var ciPaths = new List(); + if (!string.IsNullOrWhiteSpace(stagingDir)) + ciPaths.Add(stagingDir); + if (!string.IsNullOrWhiteSpace(outputDir)) + ciPaths.Add(outputDir); + return new RunnerTempFileSystem(workingRoot, ciPaths: ciPaths.Count > 0 ? ciPaths : null, inner: inner); + } +} diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index aaa9fc24e0..1baea69864 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -16,7 +16,7 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Bundling; @@ -56,7 +56,7 @@ public record AmendBundleArguments /// public partial class ChangelogBundleAmendService( ILoggerFactory logFactory, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null) : IService { /// @@ -65,7 +65,7 @@ public partial class ChangelogBundleAmendService( private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index c53acfc057..e71a45c6fa 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -18,7 +18,7 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Bundling; @@ -145,14 +145,14 @@ public record BundlePlanResult /// public partial class ChangelogBundlingService( ILoggerFactory logFactory, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null, IGitHubReleaseService? releaseService = null, CdnChangelogEntryFetcher? entryFetcher = null) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ScopedFileSystem _fileSystem = fileSystem; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly CdnChangelogEntryFetcher _entryFetcher = entryFetcher ?? new CdnChangelogEntryFetcher(logFactory); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index 43063e528f..44c3bfe071 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -10,7 +10,7 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Bundling; @@ -60,13 +60,13 @@ public record ChangelogRemoveArguments /// public class ChangelogRemoveService( ILoggerFactory logFactory, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null, IGitHubReleaseService? releaseService = null) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ScopedFileSystem _fileSystem = fileSystem; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) diff --git a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs index ccedc482d4..ef8242972b 100644 --- a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs @@ -11,7 +11,7 @@ using Elastic.Documentation.ReleaseNotes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Bundling; @@ -83,7 +83,7 @@ public static partial class ProfileFilterResolver string profileName, string? profileArgument, ChangelogConfiguration? config, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, ILogger? logger, Cancel ctx, string? profileReport = null, @@ -201,7 +201,7 @@ public static partial class ProfileFilterResolver string profileArgument, string profileReport, BundleProfile profile, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, ILogger? logger, Cancel ctx) { @@ -282,7 +282,7 @@ public static partial class ProfileFilterResolver internal static async Task ResolveListFileAsync( IDiagnosticsCollector collector, string filePath, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, Cancel ctx) { var content = await fileSystem.File.ReadAllTextAsync(filePath, ctx); @@ -356,7 +356,7 @@ public static partial class ProfileFilterResolver : new ListFileResult(null, lines, null); } - private static ProfileArgumentType DetectLocalFileType(ScopedFileSystem fileSystem, string path) => + private static ProfileArgumentType DetectLocalFileType(IChangelogFileSystem fileSystem, string path) => fileSystem.Path.GetExtension(path).ToLowerInvariant() is ".html" or ".htm" ? ProfileArgumentType.PromotionReportFile : ProfileArgumentType.UrlListFile; diff --git a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs index f9032760bb..11864f029c 100644 --- a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs +++ b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs @@ -8,17 +8,17 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Bundling; /// /// Parser for promotion report HTML files to extract PR lists /// -public partial class PromotionReportParser(ILoggerFactory logFactory, ScopedFileSystem fileSystem) +public partial class PromotionReportParser(ILoggerFactory logFactory, IChangelogFileSystem fileSystem) { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private static readonly string[] AllowedHosts = ["github.com", "buildkite.com"]; diff --git a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs index 7502fbc0e4..25406d6567 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs @@ -11,7 +11,7 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Creation; @@ -71,7 +71,7 @@ public record CreateChangelogArguments public class ChangelogCreationService( ILoggerFactory logFactory, IConfigurationContext configurationContext, -ScopedFileSystem fileSystem, +IChangelogFileSystem fileSystem, IGitHubPrService? githubPrService = null, IEnvironmentVariables? env = null ) : IService diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs index de6d820784..faec8d5d45 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -2,6 +2,8 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Documentation.FileSystems; + using System.Globalization; using System.IO.Abstractions; using System.Text.Json; @@ -20,11 +22,11 @@ public class ChangelogArtifactEvaluationService( ILoggerFactory logFactory, IGitHubPrService gitHubPrService, ICoreService coreService, - IFileSystem fileSystem + IRunnerTempFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem; + private readonly IRunnerTempFileSystem _fileSystem = fileSystem; public async Task EvaluateArtifact(IDiagnosticsCollector collector, EvaluateArtifactArguments input, Cancel ctx) { diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs index e0177b142b..15aa7c6aee 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.Buffers; +using Elastic.Documentation.FileSystems; using System.IO.Abstractions; using System.Security; using System.Text; @@ -22,7 +23,7 @@ public static class ChangelogPrBodyReader public static async Task ReadAsync( string? prBodyFile, IDiagnosticsCollector collector, - IFileSystem fileSystem, + IRunnerTempFileSystem fileSystem, CancellationToken ct) { if (string.IsNullOrWhiteSpace(prBodyFile)) diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index d8793d4510..b4e148f2aa 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -14,7 +14,7 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Evaluation; @@ -24,11 +24,11 @@ public class ChangelogPrEvaluationService( IConfigurationContext configurationContext, IGitHubPrService gitHubPrService, ICoreService coreService, - ScopedFileSystem fileSystem + IRunnerTempFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem; + private readonly IRunnerTempFileSystem _fileSystem = fileSystem; private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrArguments input, Cancel ctx) diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs index e3f2a807b9..1586e076ee 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs @@ -2,6 +2,8 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Documentation.FileSystems; + using System.IO.Abstractions; using System.Text; using System.Text.Json; @@ -20,7 +22,7 @@ public class ChangelogPrepareArtifactService( ILoggerFactory logFactory, IConfigurationContext configurationContext, ICoreService coreService, - IFileSystem fileSystem + IRunnerTempFileSystem fileSystem ) : IService { /// @@ -29,7 +31,7 @@ IFileSystem fileSystem private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem; + private readonly IRunnerTempFileSystem _fileSystem = fileSystem; private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); public async Task PrepareArtifact(IDiagnosticsCollector collector, PrepareArtifactArguments input, Cancel ctx) diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 12ae7a243e..ffe5bd6a9f 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -16,7 +16,7 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.GithubRelease; @@ -80,7 +80,7 @@ public record CreateChangelogsFromReleaseArguments public class GitHubReleaseChangelogService( ILoggerFactory logFactory, IConfigurationContext configurationContext, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, IGitHubReleaseService? releaseService = null, IGitHubPrService? prService = null, ChangelogBundlingService? bundlingService = null @@ -92,7 +92,7 @@ public class GitHubReleaseChangelogService( private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly IGitHubPrService _prService = prService ?? new GitHubPrService(logFactory); diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs index 5e609cd243..41c6ea4b66 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs @@ -6,14 +6,14 @@ using Elastic.Changelog.Rendering.Asciidoc; using Elastic.Changelog.Rendering.Markdown; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Rendering; /// /// Coordinates rendering of changelog output to different formats. /// -public class ChangelogRenderer(ScopedFileSystem fileSystem, ILogger logger) +public class ChangelogRenderer(IChangelogFileSystem fileSystem, ILogger logger) { /// /// Renders changelog output based on the specified file type. diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 3c8eeb1c3c..07d354b56f 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -14,7 +14,7 @@ using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging; using NetEscapades.EnumGenerators; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using YamlDotNet.Core; namespace Elastic.Changelog.Rendering; @@ -70,12 +70,12 @@ public enum ChangelogFileType /// public class ChangelogRenderingService( ILoggerFactory logFactory, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ScopedFileSystem _fileSystem = fileSystem; + private readonly IChangelogFileSystem _fileSystem = fileSystem; public async Task RenderChangelogs( IDiagnosticsCollector collector, diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index 1eae9745d4..48559e396c 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -6,7 +6,7 @@ using System.Text; using Elastic.Documentation; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; @@ -15,7 +15,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the breaking-changes.md changelog file /// -public class BreakingChangesMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class BreakingChangesMarkdownRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "breaking-changes.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs index 9d0e40cfdd..7bfe6e34ac 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs @@ -6,7 +6,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; @@ -15,7 +15,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for generating clean GitHub Flavored Markdown in a single changelog.md file /// -public class ChangelogGfmRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class ChangelogGfmRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "changelog.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs index c57d7b1162..5b6e54c3fa 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs @@ -4,14 +4,14 @@ using System.Collections.Generic; using System.IO.Abstractions; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Rendering.Markdown; /// /// Coordinates rendering of all markdown changelog files. /// -public class ChangelogMarkdownRenderer(ScopedFileSystem fileSystem) +public class ChangelogMarkdownRenderer(IChangelogFileSystem fileSystem) { /// /// Renders all markdown changelog files (index, breaking changes, deprecations, known issues, highlights). diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index 43843594d8..3a631e6404 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -5,7 +5,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; @@ -14,7 +14,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the deprecations.md changelog file /// -public class DeprecationsMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class DeprecationsMarkdownRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "deprecations.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index 22d39044b9..8414c8f03f 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -5,7 +5,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using static System.Globalization.CultureInfo; namespace Elastic.Changelog.Rendering.Markdown; @@ -13,7 +13,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the highlights.md changelog file /// -public class HighlightsMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class HighlightsMarkdownRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "highlights.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 318e36e884..851ee5b006 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -6,7 +6,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; @@ -15,7 +15,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the index.md changelog file containing features, enhancements, fixes, docs, regressions, and other changes /// -public class IndexMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class IndexMarkdownRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "index.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index b84e4945e7..72a3bb9e35 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -5,7 +5,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; @@ -14,7 +14,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the known-issues.md changelog file /// -public class KnownIssuesMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class KnownIssuesMarkdownRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "known-issues.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index d545d6e502..8d50f17ee6 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -6,7 +6,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Rendering.Markdown; @@ -24,9 +24,9 @@ public record PrIssueLinkOptions( /// /// Abstract base class for changelog markdown renderers /// -public abstract class MarkdownRendererBase(ScopedFileSystem fileSystem) : IChangelogMarkdownRenderer +public abstract class MarkdownRendererBase(IChangelogFileSystem fileSystem) : IChangelogMarkdownRenderer { - protected ScopedFileSystem FileSystem { get; } = fileSystem; + protected IChangelogFileSystem FileSystem { get; } = fileSystem; /// public abstract string OutputFileName { get; } diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index 491ebcc850..1a1d124027 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -12,7 +12,7 @@ using Elastic.Documentation.Integrations.S3; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Uploading; @@ -61,13 +61,13 @@ public record ChangelogUploadArguments public class ChangelogUploadService( ILoggerFactory logFactory, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null, IAmazonS3? s3Client = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 6c6cc2e2a1..f5e3860877 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -48,7 +48,7 @@ IEnvironmentVariables environmentVariables [GeneratedRegex(@"^( *output_directory:\s*).+$", RegexOptions.Multiline)] private static partial Regex BundleOutputDirectoryRegex(); - private readonly CheckoutsFileSystem _fileSystem = CheckoutsFileSystem.FromWorkingDirectory(); + private readonly ChangelogFileSystem _fileSystem = ChangelogFileSystem.FromWorkingDirectory(); private readonly ILogger _logger = logFactory.CreateLogger(); /// Create changelog.yml and the changelog/releases directory structure. /// @@ -1392,10 +1392,7 @@ public async Task EvaluatePr( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var runnerTemp = environmentVariables.GetEnvironmentVariable("RUNNER_TEMP"); - var fileSystem = new CheckoutsFileSystem( - new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - extraRoots: string.IsNullOrWhiteSpace(runnerTemp) ? null : [runnerTemp]); + var fileSystem = RunnerTempFileSystem.ForEvaluatePr(environmentVariables); IGitHubPrService prService = new GitHubPrService(logFactory); var service = new ChangelogPrEvaluationService(logFactory, configurationContext, prService, githubActionsService, fileSystem); @@ -1485,10 +1482,7 @@ public async Task PrepareArtifact( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var physical = new FileSystem(); - var fs = new CheckoutsFileSystem( - physical.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - output: string.IsNullOrWhiteSpace(outputDir) ? null : physical.DirectoryInfo.New(outputDir)).Write; + var fs = RunnerTempFileSystem.ForPrepareArtifact(stagingDir, outputDir); var service = new ChangelogPrepareArtifactService(logFactory, configurationContext, githubActionsService, fs); var args = new PrepareArtifactArguments @@ -1537,10 +1531,7 @@ public async Task EvaluateArtifact( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var metadataDir = Path.GetDirectoryName(metadata); - var fs = new CheckoutsFileSystem( - new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - extraRoots: string.IsNullOrWhiteSpace(metadataDir) ? null : [metadataDir]); + var fs = RunnerTempFileSystem.ForEvaluateArtifact(metadata); IGitHubPrService prService = new GitHubPrService(logFactory); var service = new ChangelogArtifactEvaluationService(logFactory, prService, githubActionsService, fs); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 4c777db9ec..0c7ba73650 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -26,7 +26,7 @@ public PublicOnlyAssemblerConfigurationTests() FileSystem.Path.Join(Paths.GetSolutionDirectory()!.FullName, ".artifacts", "checkouts") ); Collector = new DiagnosticsCollector([]); - var configurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, FileSystem, skipPrivateRepositories: true); + var configurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(FileSystem), skipPrivateRepositories: true); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem, configurationFileProvider: configurationFileProvider); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs b/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs index 7098a012de..26952c33a3 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging.Abstractions; @@ -24,7 +25,7 @@ public static IConfigurationContext CreateConfigurationContext( ProductsConfiguration? productsConfiguration = null ) { - configurationFileProvider ??= new ConfigurationFileProvider(NullLoggerFactory.Instance, fileSystem, skipPrivateRepositories: true); + configurationFileProvider ??= new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem), skipPrivateRepositories: true); versionsConfiguration ??= new VersionsConfiguration { VersioningSystems = new Dictionary diff --git a/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs b/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs index e0152cfc1a..19bc62be74 100644 --- a/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs +++ b/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.IO.Abstractions; using AwesomeAssertions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Search; @@ -245,7 +246,7 @@ public async Task ExplainTopResultAndExpectedAsyncReturnsDetailedScoring() private static (NavigationSearchService Gateway, ElasticsearchClientAccessor ClientAccessor) CreateFindPageGateway() { var endpoints = ElasticsearchEndpointFactory.Create(buildType: "assembler"); - var configProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new FileSystem(), configurationSource: ConfigurationSource.Embedded); + var configProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(), configurationSource: ConfigurationSource.Embedded); var searchConfig = configProvider.CreateSearchConfiguration(); var clientAccessor = new ElasticsearchClientAccessor(endpoints, searchConfig); diff --git a/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs b/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs index 450c7b2a22..3b03b19924 100644 --- a/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs +++ b/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs @@ -14,6 +14,7 @@ using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging.Abstractions; @@ -51,7 +52,7 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS { Elasticsearch = ElasticsearchEndpoint.Default, }, - ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, fileSystem), + ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem)), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, LegacyUrlMappings = new LegacyUrlMappingConfiguration { Mappings = [] }, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 313acf2a73..8f195f8c52 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -3263,7 +3263,7 @@ public async Task BundleChangelogs_WithProfileMode_MissingConfig_ReturnsErrorWit currentDirectory: "/empty-project" ); cwdFs.Directory.CreateDirectory("/empty-project"); - var service = new ChangelogBundlingService(LoggerFactory, CheckoutsFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); + var service = new ChangelogBundlingService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new BundleChangelogsArguments { @@ -3324,7 +3324,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSucce """; await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); - var service = new ChangelogBundlingService(LoggerFactory, CheckoutsFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); + var service = new ChangelogBundlingService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new BundleChangelogsArguments { @@ -3385,7 +3385,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce """; await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); - var service = new ChangelogBundlingService(LoggerFactory, CheckoutsFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); + var service = new ChangelogBundlingService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new BundleChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs index b7a2155078..27ebb30c73 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -458,7 +458,7 @@ public async Task Remove_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() currentDirectory: "/empty-project" ); cwdFs.Directory.CreateDirectory("/empty-project"); - var service = new ChangelogRemoveService(LoggerFactory, CheckoutsFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); + var service = new ChangelogRemoveService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new ChangelogRemoveArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs index 6bf1db2574..94d406e1e2 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs @@ -15,13 +15,13 @@ using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Tests.Changelogs; public abstract class ChangelogTestBase : IDisposable { - protected ScopedFileSystem FileSystem { get; } + protected ChangelogFileSystem FileSystem { get; } + protected RunnerTempFileSystem RunnerTempFileSystem { get; } protected IConfigurationContext ConfigurationContext { get; } protected TestDiagnosticsCollector Collector { get; } protected ILoggerFactory LoggerFactory { get; } @@ -31,7 +31,14 @@ protected ChangelogTestBase(ITestOutputHelper output) { Output = output; var mockFileSystem = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - FileSystem = CheckoutsFileSystem.FromWorkingDirectory(mockFileSystem); + FileSystem = ChangelogFileSystem.FromWorkingDirectory(mockFileSystem); + RunnerTempFileSystem = new RunnerTempFileSystem( + mockFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + inner: mockFileSystem); + // ConfigurationFileProvider writes to AppData/config-runtime, which is outside ChangelogFileSystem's + // git-root scope by design. Use a CheckoutsFileSystem (includes AppData) for the config provider only; + // it wraps the same mock so both filesystems share in-memory state. + var configFileSystem = CheckoutsFileSystem.FromWorkingDirectory(mockFileSystem); Collector = new TestDiagnosticsCollector(output); LoggerFactory = new TestLoggerFactory(output); @@ -105,7 +112,7 @@ protected ChangelogTestBase(ITestOutputHelper output) { Elasticsearch = ElasticsearchEndpoint.Default, }, - ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, FileSystem), + ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, configFileSystem), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, SearchConfiguration = new SearchConfiguration { Synonyms = [], Rules = [], DiminishTerms = [] }, diff --git a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs index c116842877..043d536f0f 100644 --- a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs @@ -190,24 +190,16 @@ public async Task CreateChangelog_CIWithoutProducts_NoPrProductLabels_FailsWithP } /// - /// When --output points to a temp directory (e.g. /tmp/changelog-staging in CI), - /// the service must use a write-scoped filesystem that allows temp paths. - /// Regression test for ScopedFileSystemException on temp output. + /// When --output points to a subdirectory of the repo root, the service writes changelog files there. /// [Fact] - public async Task CreateChangelog_TempOutputDirectory_Succeeds() + public async Task CreateChangelog_OutputSubdirectory_Succeeds() { - var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - var writeFs = CheckoutsFileSystem.FromWorkingDirectory(mockFs).Write; - var configPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"); - writeFs.Directory.CreateDirectory(writeFs.Path.GetDirectoryName(configPath)!); - await writeFs.File.WriteAllTextAsync(configPath, ConfigWithProductLabels, TestContext.Current.CancellationToken); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, ConfigWithProductLabels, TestContext.Current.CancellationToken); - // Use the real system temp path so AllowedSpecialFolder.Temp matches cross-platform. - // MockFileSystem's GetTempPath() returns a hardcoded "C:\temp" that diverges from the - // real temp on Windows CI (D:\Temp), causing scope validation to fail. - var tempOutput = Path.Join(Path.GetTempPath(), "changelog-staging"); + var output = Path.Join(Paths.WorkingDirectoryRoot.FullName, "changelog-staging"); var env = FakeCIEnv( prNumber: "1044", @@ -218,12 +210,12 @@ public async Task CreateChangelog_TempOutputDirectory_Succeeds() products: "elasticsearch" ); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, writeFs, _mockGitHub, env); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, env); var input = new CreateChangelogArguments { Products = [], Config = configPath, - Output = tempOutput, + Output = output, Concise = true }; @@ -231,8 +223,8 @@ public async Task CreateChangelog_TempOutputDirectory_Succeeds() result.Should().BeTrue(); Collector.Errors.Should().Be(0); - writeFs.Directory.Exists(tempOutput).Should().BeTrue(); - writeFs.Directory.GetFiles(tempOutput, "*.yaml").Should().NotBeEmpty(); + FileSystem.Directory.Exists(output).Should().BeTrue(); + FileSystem.Directory.GetFiles(output, "*.yaml").Should().NotBeEmpty(); } [Fact] diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs index 7d662e4c8c..6148df8634 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs @@ -24,7 +24,7 @@ public class ChangelogArtifactEvaluationServiceTests(ITestOutputHelper output) : private static readonly string MetadataFilePath = Path.Join(Root, "artifact/metadata.json"); private ChangelogArtifactEvaluationService CreateService() => - new(LoggerFactory, _mockGitHub, _mockCore, FileSystem); + new(LoggerFactory, _mockGitHub, _mockCore, RunnerTempFileSystem); private static EvaluateArtifactArguments DefaultArgs() => new() diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs index 22501fbb35..32043275d0 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs @@ -20,8 +20,9 @@ public async Task ReadAsync_PrBodyFileUnderWorkingDir_ReadsBody() var mockFs = CreateMockFileSystem(); mockFs.AddFile(bodyPath, new MockFileData("Release Notes: adds billing metadata")); var collector = new TestDiagnosticsCollector(output); + var fs = CreateRunnerTempFs(mockFs); - var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, mockFs, TestContext.Current.CancellationToken); + var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, fs, TestContext.Current.CancellationToken); result.Should().Be("Release Notes: adds billing metadata"); collector.Diagnostics.Should().BeEmpty(); @@ -31,7 +32,8 @@ public async Task ReadAsync_PrBodyFileUnderWorkingDir_ReadsBody() public async Task ReadAsync_PrBodyFileMissing_EmitsWarning() { var bodyPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "missing-pr-body.md"); - var scopedFs = CheckoutsFileSystem.FromWorkingDirectory(CreateMockFileSystem()); + var mockFs = CreateMockFileSystem(); + var scopedFs = CreateRunnerTempFs(mockFs); var collector = new TestDiagnosticsCollector(output); var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, scopedFs, TestContext.Current.CancellationToken); @@ -49,7 +51,8 @@ public async Task ReadAsync_PrBodyFileOutsideScope_EmitsWarning() var bodyPath = Path.Join(runnerTemp, "changelog-pr-body.md"); var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); mockFs.AddFile(bodyPath, new MockFileData("Release Notes: something important")); - var scopedFs = CheckoutsFileSystem.FromWorkingDirectory(mockFs); + // Deliberately do NOT add runnerTemp to ciPaths — file should be outside scope + var scopedFs = CreateRunnerTempFs(mockFs); var collector = new TestDiagnosticsCollector(output); var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, scopedFs, TestContext.Current.CancellationToken); @@ -68,8 +71,9 @@ public async Task ReadAsync_PrBodyFileExceedsMaxSize_TruncatesAndHints() var mockFs = CreateMockFileSystem(); mockFs.AddFile(bodyPath, new MockFileData(largeContent)); var collector = new TestDiagnosticsCollector(output); + var fs = CreateRunnerTempFs(mockFs); - var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, mockFs, TestContext.Current.CancellationToken); + var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, fs, TestContext.Current.CancellationToken); result.Should().NotBeNull(); result.Length.Should().Be(ChangelogPrBodyReader.MaxPrBodyFileBytes); @@ -78,4 +82,7 @@ public async Task ReadAsync_PrBodyFileExceedsMaxSize_TruncatesAndHints() private static MockFileSystem CreateMockFileSystem() => new(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); + + private static RunnerTempFileSystem CreateRunnerTempFs(MockFileSystem inner) => + new(inner.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: inner); } diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs index 1419ddf75e..da29e64f33 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs @@ -67,7 +67,7 @@ public ChangelogPrEvaluationServiceTests(ITestOutputHelper output) : base(output } private ChangelogPrEvaluationService CreateService() => - new(LoggerFactory, ConfigurationContext, _mockGitHub, _mockCore, FileSystem); + new(LoggerFactory, ConfigurationContext, _mockGitHub, _mockCore, RunnerTempFileSystem); private EvaluatePrArguments DefaultArgs( string eventAction = "opened", diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs index 464bcaf16c..bff6350b26 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs @@ -43,7 +43,7 @@ public class ChangelogPrepareArtifactServiceTests(ITestOutputHelper output) : Ch """; private ChangelogPrepareArtifactService CreateService() => - new(LoggerFactory, ConfigurationContext, _mockCore, FileSystem); + new(LoggerFactory, ConfigurationContext, _mockCore, RunnerTempFileSystem); private PrepareArtifactArguments DefaultArgs( string evaluateStatus = "proceed", diff --git a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs index 92654c4d2f..e4ddb20204 100644 --- a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs @@ -21,7 +21,7 @@ namespace Elastic.Changelog.Tests.Uploading; public class ChangelogUploadServiceTests { private readonly MockFileSystem _mockFileSystem; - private readonly CheckoutsFileSystem _fileSystem; + private readonly ChangelogFileSystem _fileSystem; private readonly IAmazonS3 _s3Client = A.Fake(); private readonly ChangelogUploadService _service; private readonly TestDiagnosticsCollector _collector; @@ -33,7 +33,7 @@ public ChangelogUploadServiceTests(ITestOutputHelper output) { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - _fileSystem = CheckoutsFileSystem.FromWorkingDirectory(_mockFileSystem); + _fileSystem = ChangelogFileSystem.FromWorkingDirectory(_mockFileSystem); _service = new ChangelogUploadService(NullLoggerFactory.Instance, fileSystem: _fileSystem, s3Client: _s3Client); _collector = new TestDiagnosticsCollector(output); _changelogDir = _mockFileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog"); diff --git a/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs b/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs index 804502ebca..b9a7f84465 100644 --- a/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs +++ b/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs @@ -15,6 +15,7 @@ using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using FakeItEasy; using Microsoft.Extensions.Logging; @@ -71,7 +72,7 @@ public static IConfigurationContext CreateConfigurationContext( { Elasticsearch = ElasticsearchEndpoint.Default, }, - ConfigurationFileProvider = new ConfigurationFileProvider(new TestLoggerFactory(null), fileSystem), + ConfigurationFileProvider = new ConfigurationFileProvider(new TestLoggerFactory(null), new ConfigurationFileSystem(fileSystem)), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, SearchConfiguration = search, diff --git a/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs index b54ec47e48..b2bf2f89e6 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Documentation.Configuration.Tests; @@ -12,7 +13,7 @@ namespace Elastic.Documentation.Configuration.Tests; public class CreateNavigationFileTests { private static ConfigurationFileProvider CreateProvider(MockFileSystem fileSystem) => - new(NullLoggerFactory.Instance, fileSystem, skipPrivateRepositories: true, ConfigurationSource.Embedded); + new(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem), skipPrivateRepositories: true, ConfigurationSource.Embedded); private static AssemblyConfiguration CreateConfig(params string[] privateRepoNames) { diff --git a/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs index dcaf7ef439..9984c43381 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs @@ -6,6 +6,7 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Documentation.Configuration.Tests; @@ -107,8 +108,7 @@ public void GetProductByRepositoryName_WorksForProductsWithDisabledFeatures() private static ProductsConfiguration LoadActualProductsConfiguration() { - var fileSystem = new FileSystem(); - var provider = new ConfigurationFileProvider(new NullLoggerFactory(), fileSystem); + var provider = new ConfigurationFileProvider(new NullLoggerFactory(), new ConfigurationFileSystem()); var versionsConfig = provider.CreateVersionConfiguration(); return provider.CreateProducts(versionsConfig); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs b/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs index 4ea6c22fd2..df8a2b10d2 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs @@ -5,6 +5,7 @@ using System.Collections.Frozen; using System.IO.Abstractions; using AwesomeAssertions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration.Inference; using Elastic.Documentation.Configuration.Products; @@ -498,7 +499,7 @@ public void IsVersionlessCorrectlyIdentifiesAllVersionlessSystemsFromActualConfi var versionsPath = fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "versions.yml"); File.Exists(versionsPath).Should().BeTrue($"Expected versions file to exist at {versionsPath}"); - var provider = new ConfigurationFileProvider(new NullLoggerFactory(), fileSystem); + var provider = new ConfigurationFileProvider(new NullLoggerFactory(), new ConfigurationFileSystem()); var versionsConfig = provider.CreateVersionConfiguration(); // Verify all expected versionless systems are marked as versionless @@ -530,7 +531,7 @@ public void AllVersioningSystemsInConfigAreAccountedFor() var versionsPath = fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "versions.yml"); File.Exists(versionsPath).Should().BeTrue($"Expected versions file to exist at {versionsPath}"); - var provider = new ConfigurationFileProvider(new NullLoggerFactory(), fileSystem); + var provider = new ConfigurationFileProvider(new NullLoggerFactory(), new ConfigurationFileSystem()); var versionsConfig = provider.CreateVersionConfiguration(); // Count how many are versionless vs versioned diff --git a/tests/Elastic.Markdown.Tests/TestHelpers.cs b/tests/Elastic.Markdown.Tests/TestHelpers.cs index 5f14bc07c1..07296fc9c4 100644 --- a/tests/Elastic.Markdown.Tests/TestHelpers.cs +++ b/tests/Elastic.Markdown.Tests/TestHelpers.cs @@ -136,7 +136,7 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS { Elasticsearch = ElasticsearchEndpoint.Default, }, - ConfigurationFileProvider = new ConfigurationFileProvider(new TestLoggerFactory(TestContext.Current.TestOutputHelper), fileSystem), + ConfigurationFileProvider = new ConfigurationFileProvider(new TestLoggerFactory(TestContext.Current.TestOutputHelper), new ConfigurationFileSystem(fileSystem)), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, SearchConfiguration = search, diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index 00a04d973e..89e52531ae 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -469,7 +469,7 @@ type Setup = DisplayName = "Elastic Cloud Control ECCTL", VersioningSystem = versionConfig.VersioningSystems[VersioningSystemId.Ecctl])) - let configurationFileProvider = ConfigurationFileProvider(new TestLoggerFactory(), fileSystem) + let configurationFileProvider = ConfigurationFileProvider(new TestLoggerFactory(), ConfigurationFileSystem(fileSystem)) let configurationContext = ConfigurationContext( VersionsConfiguration = versionConfig, ConfigurationFileProvider = configurationFileProvider, From f8c4ba175a820a6c3e6dc3b3d7efe5c4103b4a9d Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 14:35:00 +0200 Subject: [PATCH 20/29] Remove ReadFileSystem from IDocumentationContext base interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IDocumentationContext no longer carries ReadFileSystem — no callers ever read it through the base interface. Push it down to where it is actually used: IDocumentationSetContext gets IDocumentationFileSystem ReadFileSystem, IDocsSyncContext gets CheckoutsFileSystem ReadFileSystem. Move IDocumentationFileSystem from Tooling to the Elastic.Documentation base project to avoid circular dependencies. Also: delete the dead reference-identity branch in DocumentationGenerator.CopyFileAcrossFileSystems (read and write filesystems are always distinct objects in BuildContext), and harden FindGitRoot and GitResolveFileSystem.BuildOptions against missing directories and scope boundaries in mock filesystems. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Building/CodexBuildService.cs | 3 --- src/Elastic.Codex/CodexContext.cs | 5 ----- .../BuildContext.cs | 7 +++--- .../FileSystems/GitResolveFileSystem.cs | 8 +++++-- src/Elastic.Documentation.Tooling/Paths.cs | 22 +++++++++++++++++-- .../FileSystems/IDocumentationFileSystem.cs | 4 ++-- .../IDocumentationContext.cs | 3 +-- .../DocumentationGenerator.cs | 9 ++------ .../Myst/Directives/CsvInclude/CsvReader.cs | 3 +-- .../AssembleContext.cs | 8 ------- .../Synchronization/IDocsSyncContext.cs | 3 +-- .../ApiConfigurationTests.cs | 5 +++-- .../ConfigurationFileExcludeTests.cs | 3 +-- .../ConfigurationFileReleaseNotesTests.cs | 3 +-- ...ConfigurationFileStorybookRegistryTests.cs | 3 +-- .../CrossLinkRegistryTests.cs | 3 +-- .../Assembler/SiteNavigationTestFixture.cs | 5 +++++ .../Codex/CodexNavigationTestBase.cs | 2 -- .../Codex/GroupNavigationTests.cs | 2 -- .../Isolation/PhysicalDocsetTests.cs | 3 ++- .../TestDocumentationSetContext.cs | 5 ++--- .../Framework/CrossLinkResolverAssertions.fs | 3 ++- tests/authoring/Framework/Setup.fs | 2 +- tests/authoring/Framework/TestValues.fs | 3 ++- 24 files changed, 57 insertions(+), 60 deletions(-) rename src/{Elastic.Documentation.Tooling => Elastic.Documentation}/FileSystems/IDocumentationFileSystem.cs (76%) diff --git a/src/Elastic.Codex/Building/CodexBuildService.cs b/src/Elastic.Codex/Building/CodexBuildService.cs index 705b6c85fd..79f3bb78e2 100644 --- a/src/Elastic.Codex/Building/CodexBuildService.cs +++ b/src/Elastic.Codex/Building/CodexBuildService.cs @@ -411,9 +411,6 @@ internal sealed class CodexDocumentationContext(CodexContext codexContext) : ICo /// public IDiagnosticsCollector Collector => codexContext.Collector; - /// - public ScopedFileSystem ReadFileSystem => codexContext.ReadFileSystem; - /// public DocumentationWriteFileSystem WriteFileSystem => codexContext.WriteFileSystem; diff --git a/src/Elastic.Codex/CodexContext.cs b/src/Elastic.Codex/CodexContext.cs index 3ff78acf97..ef99f62a62 100644 --- a/src/Elastic.Codex/CodexContext.cs +++ b/src/Elastic.Codex/CodexContext.cs @@ -8,7 +8,6 @@ using Elastic.Documentation.Deploying.Synchronization; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Nullean.ScopedFileSystem; namespace Elastic.Codex; @@ -17,10 +16,6 @@ namespace Elastic.Codex; /// public class CodexContext : IDocsSyncContext { - // Explicit implementation satisfies the interface contract; public property exposes - // the narrower type. Removed when IDocsSyncContext.ReadFileSystem narrows to - // CheckoutsFileSystem in commit 6. - ScopedFileSystem IDocsSyncContext.ReadFileSystem => ReadFileSystem; public CheckoutsFileSystem ReadFileSystem { get; } public DocumentationWriteFileSystem WriteFileSystem { get; } public IDiagnosticsCollector Collector { get; } diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 86c7e00317..486bf290cd 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -27,11 +27,10 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public DocumentationFileSystem FileSystem { get; } /// - /// Read scope. Returns the underlying as a - /// to satisfy . + /// Read scope. Satisfies . /// Use directly when the richer type is needed. /// - public ScopedFileSystem ReadFileSystem => FileSystem.Read; + public IDocumentationFileSystem ReadFileSystem => FileSystem.Read; /// Write scope. Does not permit .git writes. public DocumentationWriteFileSystem WriteFileSystem => FileSystem.Write; @@ -108,7 +107,7 @@ public void ReloadConfiguration() { var previousFeatures = Configuration.Features; ConfigurationYaml = ConfigurationPath.Exists - ? DocumentationSetFile.LoadAndResolve(Collector, ConfigurationPath, ReadFileSystem) + ? DocumentationSetFile.LoadAndResolve(Collector, ConfigurationPath, ReadFileSystem as ScopedFileSystem) : new DocumentationSetFile(); Configuration = new ConfigurationFile(ConfigurationYaml, this, VersionsConfiguration, ProductsConfiguration); Configuration.Features.DiagnosticsPanelEnabled = previousFeatures.DiagnosticsPanelEnabled; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs index 849319bd96..4769ffdee9 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs @@ -45,10 +45,14 @@ private static ScopedFileSystemOptions BuildOptions( for (var i = 0; i < maxParents; i++) root = root.Parent ?? root; - var rootPath = root.FullName; + // ScopedFileSystem normalises scope roots via TrimEnd(separator). On Unix, "/" trimmed is "". + // An empty scope root makes every path fail the IsWithinRoot check, so guard against it: + // if the computed root IS the filesystem root, fall back to the anchor itself. + var fs = anchor.FileSystem; + var normalised = root.FullName.TrimEnd(fs.Path.DirectorySeparatorChar, fs.Path.AltDirectorySeparatorChar); + var rootPath = string.IsNullOrEmpty(normalised) ? anchor.FullName : root.FullName; var roots = new List { rootPath }; - var fs = anchor.FileSystem; if (gitDirectories is { Count: > 0 }) { foreach (var gitDir in gitDirectories) diff --git a/src/Elastic.Documentation.Tooling/Paths.cs b/src/Elastic.Documentation.Tooling/Paths.cs index cceea2f5e0..fb3be94d69 100644 --- a/src/Elastic.Documentation.Tooling/Paths.cs +++ b/src/Elastic.Documentation.Tooling/Paths.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Abstractions; +using System.Security; using Elastic.Documentation.Extensions; // ReSharper disable once CheckNamespace — intentionally preserving the original namespace so consumers need no using changes @@ -39,8 +40,25 @@ public static class Paths var depth = 0; while (directory != null) { - var hasGit = directory.GetDirectories(".git").Length > 0 - || directory.GetFiles(".git").Length > 0; + bool hasGit; + try + { + hasGit = directory.GetDirectories(".git").Length > 0 + || directory.GetFiles(".git").Length > 0; + } + catch (DirectoryNotFoundException) + { + // Directory does not exist in the (mock) filesystem — no .git here. + // Continue up the tree so the caller can decide. + hasGit = false; + } + catch (SecurityException) + { + // A ScopedFileSystem is blocking access to this directory (e.g. the scope root + // is the anchor itself so the parent is outside scope). Stop searching. + return null; + } + if (hasGit) { #if DEBUG diff --git a/src/Elastic.Documentation.Tooling/FileSystems/IDocumentationFileSystem.cs b/src/Elastic.Documentation/FileSystems/IDocumentationFileSystem.cs similarity index 76% rename from src/Elastic.Documentation.Tooling/FileSystems/IDocumentationFileSystem.cs rename to src/Elastic.Documentation/FileSystems/IDocumentationFileSystem.cs index 9f24ab94fc..3bb6fe9984 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/IDocumentationFileSystem.cs +++ b/src/Elastic.Documentation/FileSystems/IDocumentationFileSystem.cs @@ -8,9 +8,9 @@ namespace Elastic.Documentation.FileSystems; /// /// Marker interface for the documentation-set scope: a single checked-out repository -/// anchored to a docset.yml. Only implements +/// anchored to a docset.yml. Only DocumentationFileSystem implements /// this; declaring it on parameters ensures the compiler rejects an assembler-scope -/// or a changelog-scope +/// ICheckoutsFileSystem or a changelog-scope IChangelogFileSystem /// where a docset-anchored read scope is required. /// public interface IDocumentationFileSystem : IFileSystem; diff --git a/src/Elastic.Documentation/IDocumentationContext.cs b/src/Elastic.Documentation/IDocumentationContext.cs index 424c432c9e..043f1b428c 100644 --- a/src/Elastic.Documentation/IDocumentationContext.cs +++ b/src/Elastic.Documentation/IDocumentationContext.cs @@ -5,14 +5,12 @@ using System.IO.Abstractions; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation; public interface IDocumentationContext { IDiagnosticsCollector Collector { get; } - ScopedFileSystem ReadFileSystem { get; } DocumentationWriteFileSystem WriteFileSystem { get; } IDirectoryInfo OutputDirectory { get; } IFileInfo ConfigurationPath { get; } @@ -21,6 +19,7 @@ public interface IDocumentationContext public interface IDocumentationSetContext : IDocumentationContext { + IDocumentationFileSystem ReadFileSystem { get; } IDirectoryInfo DocumentationSourceDirectory { get; } GitCheckoutInformation Git { get; } diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index e5d4f08fe4..9853605938 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -239,13 +239,8 @@ private void CopyBrandingResources() private void CopyFileAcrossFileSystems(IFileInfo source, IFileInfo destination) { - if (Context.ReadFileSystem == _writeFileSystem) - Context.ReadFileSystem.File.Copy(source.FullName, destination.FullName, overwrite: true); - else - { - var bytes = Context.ReadFileSystem.File.ReadAllBytes(source.FullName); - _writeFileSystem.File.WriteAllBytes(destination.FullName, bytes); - } + var bytes = Context.ReadFileSystem.File.ReadAllBytes(source.FullName); + _writeFileSystem.File.WriteAllBytes(destination.FullName, bytes); } private void HintUnusedSubstitutionKeys() diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs index e60b3bc3e6..681b7189ed 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs @@ -5,13 +5,12 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; using nietras.SeparatedValues; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Myst.Directives.CsvInclude; public static class CsvReader { - public static IEnumerable ReadCsvFile(string filePath, string separator, ScopedFileSystem fileSystem) + public static IEnumerable ReadCsvFile(string filePath, string separator, IFileSystem fileSystem) { return ReadWithSep(filePath, separator, fileSystem); } diff --git a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs index 794cd09214..b28196b311 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs @@ -12,19 +12,11 @@ using Elastic.Documentation.Deploying.Synchronization; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler; public class AssembleContext : IDocumentationConfigurationContext, IDocsSyncContext { - // Explicit implementations satisfy the interface contracts (ScopedFileSystem); - // the public property exposes the narrower CheckoutsFileSystem for code that knows - // the concrete context type. Commit 6 removes ReadFileSystem from IDocumentationContext - // and retypes IDocsSyncContext.ReadFileSystem to CheckoutsFileSystem, at which point - // these explicit implementations can be deleted. - ScopedFileSystem IDocumentationContext.ReadFileSystem => ReadFileSystem; - ScopedFileSystem IDocsSyncContext.ReadFileSystem => ReadFileSystem; public CheckoutsFileSystem ReadFileSystem { get; } public DocumentationWriteFileSystem WriteFileSystem { get; } diff --git a/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs b/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs index 2d57ca5614..7ef483a422 100644 --- a/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs +++ b/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs @@ -5,7 +5,6 @@ using System.IO.Abstractions; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Deploying.Synchronization; @@ -15,7 +14,7 @@ namespace Elastic.Documentation.Deploying.Synchronization; /// public interface IDocsSyncContext { - ScopedFileSystem ReadFileSystem { get; } + CheckoutsFileSystem ReadFileSystem { get; } DocumentationWriteFileSystem WriteFileSystem { get; } IDirectoryInfo OutputDirectory { get; } IDiagnosticsCollector Collector { get; } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index d82965751e..8739428e40 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs @@ -13,7 +13,6 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; using YamlDotNet.Core; using YamlDotNet.Serialization; @@ -640,7 +639,9 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => WriteFileSystem; + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve( + documentationSourceDirectory, + new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configurationPath }); public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs index e17893f2a3..1d92535ac1 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs @@ -12,7 +12,6 @@ using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration.Tests; @@ -88,7 +87,7 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => WriteFileSystem; + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs index 0251cce6d2..b541ccb920 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs @@ -12,7 +12,6 @@ using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration.Tests; @@ -157,7 +156,7 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => WriteFileSystem; + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs index 088d097574..af6bedef33 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs @@ -12,7 +12,6 @@ using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration.Tests; @@ -106,7 +105,7 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => WriteFileSystem; + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); diff --git a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs index 3b82f69886..34bfb84791 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs @@ -12,7 +12,6 @@ using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration.Tests; @@ -134,7 +133,7 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => WriteFileSystem; + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs index a9555fb552..7a15d0e7a8 100644 --- a/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs @@ -34,6 +34,7 @@ private static void SetupServerlessObservabilityRepository(MockFileSystem fileSy { var baseDir = "/checkouts/current/observability"; fileSystem.AddDirectory(baseDir); + fileSystem.AddDirectory($"{baseDir}/.git"); // Add docset.yml // language=yaml @@ -68,6 +69,7 @@ private static void SetupServerlessSearchRepository(MockFileSystem fileSystem) { var baseDir = "/checkouts/current/serverless-search"; fileSystem.AddDirectory(baseDir); + fileSystem.AddDirectory($"{baseDir}/.git"); // Add docset.yml // language=yaml @@ -100,6 +102,7 @@ private static void SetupServerlessSecurityRepository(MockFileSystem fileSystem) { var baseDir = "/checkouts/current/serverless-security"; fileSystem.AddDirectory(baseDir); + fileSystem.AddDirectory($"{baseDir}/.git"); // Add docset.yml with underscore prefix // language=yaml @@ -132,6 +135,7 @@ private static void SetupPlatformRepository(MockFileSystem fileSystem) { var baseDir = "/checkouts/current/platform"; fileSystem.AddDirectory(baseDir); + fileSystem.AddDirectory($"{baseDir}/.git"); // Add docset.yml // language=yaml @@ -186,6 +190,7 @@ private static void SetupElasticsearchReferenceRepository(MockFileSystem fileSys { var baseDir = "/checkouts/current/elasticsearch-reference"; fileSystem.AddDirectory(baseDir); + fileSystem.AddDirectory($"{baseDir}/.git"); // Add docset.yml // language=yaml diff --git a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs index 2650782246..cc5bbef219 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs @@ -12,7 +12,6 @@ using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Isolated.Node; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -80,7 +79,6 @@ internal sealed class TestCodexDocumentationContext(IDiagnosticsCollector collec public IFileInfo ConfigurationPath => _fileSystem.FileInfo.New(_fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => CheckoutsFileSystem.FromWorkingDirectory(_fileSystem); public DocumentationWriteFileSystem WriteFileSystem => new(_fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fileSystem); public IDirectoryInfo OutputDirectory => _fileSystem.DirectoryInfo.New(_fileSystem.Path.Join(Paths.ApplicationData.FullName, "codex", "output")); public BuildType BuildType => BuildType.Codex; diff --git a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs index a6440c2887..7a2de3d6b6 100644 --- a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs +++ b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs @@ -6,7 +6,6 @@ using Elastic.Codex.Navigation; using Elastic.Documentation.Configuration; using Elastic.Documentation.FileSystems; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -139,7 +138,6 @@ private sealed class MinimalCodexContext : ICodexDocumentationContext private readonly System.IO.Abstractions.TestingHelpers.MockFileSystem _fs = new(); public System.IO.Abstractions.IFileInfo ConfigurationPath => _fs.FileInfo.New("/codex.yml"); public Elastic.Documentation.Diagnostics.IDiagnosticsCollector Collector => new Elastic.Documentation.Diagnostics.DiagnosticsCollector([]); - public ScopedFileSystem ReadFileSystem => CheckoutsFileSystem.FromWorkingDirectory(_fs); public DocumentationWriteFileSystem WriteFileSystem => new(_fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fs); public System.IO.Abstractions.IDirectoryInfo OutputDirectory => _fs.DirectoryInfo.New("/output"); public BuildType BuildType => BuildType.Codex; diff --git a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs index 3c3dbf4e6c..9c4d59da4b 100644 --- a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs +++ b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Isolated.Leaf; +using Nullean.ScopedFileSystem; using Elastic.Documentation.Navigation.Isolated.Node; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -28,7 +29,7 @@ public async Task PhysicalDocsetCanBeNavigated() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, context.ReadFileSystem, noSuppress: [HintType.DeepLinkingVirtualFile]); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, context.ReadFileSystem as ScopedFileSystem, noSuppress: [HintType.DeepLinkingVirtualFile]); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/Navigation.Tests/TestDocumentationSetContext.cs b/tests/Navigation.Tests/TestDocumentationSetContext.cs index 3ecc53ed14..330e911bd3 100644 --- a/tests/Navigation.Tests/TestDocumentationSetContext.cs +++ b/tests/Navigation.Tests/TestDocumentationSetContext.cs @@ -15,7 +15,6 @@ using Markdig.Parsers; using Markdig.Syntax; using Markdig.Syntax.Inlines; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests; @@ -84,7 +83,7 @@ public TestDocumentationSetContext(IFileSystem fileSystem, TestDiagnosticsCollector? collector = null ) { - ReadFileSystem = new CheckoutsFileSystem(sourceDirectory, inner: fileSystem); + ReadFileSystem = DocumentationFileSystem.Resolve(sourceDirectory, new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configPath }); WriteFileSystem = new DocumentationWriteFileSystem(sourceDirectory, outputDirectory, fileSystem); DocumentationSourceDirectory = sourceDirectory; OutputDirectory = outputDirectory; @@ -103,7 +102,7 @@ public TestDocumentationSetContext(IFileSystem fileSystem, } public IDiagnosticsCollector Collector { get; } - public ScopedFileSystem ReadFileSystem { get; } + public IDocumentationFileSystem ReadFileSystem { get; } public DocumentationWriteFileSystem WriteFileSystem { get; } public IDirectoryInfo OutputDirectory { get; } public IDirectoryInfo DocumentationSourceDirectory { get; } diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs index 0910fc611c..ce3811f814 100644 --- a/tests/authoring/Framework/CrossLinkResolverAssertions.fs +++ b/tests/authoring/Framework/CrossLinkResolverAssertions.fs @@ -7,6 +7,7 @@ namespace authoring open System open System.Collections.Generic open System.Collections.Frozen +open System.IO.Abstractions open System.IO.Abstractions.TestingHelpers open Elastic.Documentation.Diagnostics open Elastic.Documentation.Links @@ -32,7 +33,7 @@ module CrossLinkResolverAssertions = member _.Collector = collector member _.DocumentationSourceDirectory = mockFileSystem.DirectoryInfo.New("/docs") member _.Git = GitCheckoutInformation.Unavailable - member _.ReadFileSystem = CheckoutsFileSystem.FromWorkingDirectory(mockFileSystem) + member _.ReadFileSystem = DocumentationFileSystem.Resolve(mockFileSystem.DirectoryInfo.New("/docs"), DocumentationScopeOptions(Inner = (mockFileSystem :> IFileSystem), ConfigurationFile = mockFileSystem.FileInfo.New("/docs/docset.yml"))) :> IDocumentationFileSystem member _.WriteFileSystem = DocumentationWriteFileSystem(mockFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, mockFileSystem) member _.ConfigurationPath = mockFileSystem.FileInfo.New("mock_docset.yml") member _.OutputDirectory = mockFileSystem.DirectoryInfo.New(".artifacts") diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index 89e52531ae..a62ca2458a 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -510,7 +510,7 @@ type Setup = ConversionCollector= conversionCollector Set = set Generator = generator - ReadFileSystem = fileSystem + ReadFileSystem = docFs :> IDocumentationFileSystem WriteFileSystem = fileSystem } context.Bootstrap() diff --git a/tests/authoring/Framework/TestValues.fs b/tests/authoring/Framework/TestValues.fs index b2be8ff49a..4359970a3f 100644 --- a/tests/authoring/Framework/TestValues.fs +++ b/tests/authoring/Framework/TestValues.fs @@ -7,6 +7,7 @@ namespace authoring open System open System.Collections.Concurrent open System.IO.Abstractions +open Elastic.Documentation.FileSystems open Elastic.Documentation.Diagnostics open Elastic.Markdown open Elastic.Markdown.IO @@ -94,7 +95,7 @@ and MarkdownTestContext = ConversionCollector: TestConversionCollector Set: DocumentationSet Generator: DocumentationGenerator - ReadFileSystem: IFileSystem + ReadFileSystem: IDocumentationFileSystem WriteFileSystem: IFileSystem } From 469ee92b84d43e9ae648fbadc4f3a3ee3f894619 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 14:39:20 +0200 Subject: [PATCH 21/29] Make DocumentationCheckoutDirectory non-nullable ResolvedDocumentationPaths.CheckoutDirectory is required (always set), so BuildContext.DocumentationCheckoutDirectory can safely drop the ? and all callers no longer need null-guards or fallback expressions. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../BuildContext.cs | 2 +- .../ElasticsearchMarkdownExporter.Export.cs | 4 ++-- .../Extensions/DetectionRules/DetectionRuleFile.cs | 2 +- src/Elastic.Markdown/HtmlWriter.cs | 7 ++++--- src/Elastic.Markdown/IO/DocumentationSet.cs | 2 +- .../Myst/Directives/Changelog/ChangelogBlock.cs | 2 +- .../docs-builder/Http/ReloadGeneratorService.cs | 14 +++----------- 7 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 486bf290cd..d4337ac18a 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -37,7 +37,7 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public IReadOnlySet AvailableExporters { get; init; } - public IDirectoryInfo? DocumentationCheckoutDirectory => FileSystem.Paths.CheckoutDirectory; + public IDirectoryInfo DocumentationCheckoutDirectory => FileSystem.Paths.CheckoutDirectory; public IDirectoryInfo DocumentationSourceDirectory => FileSystem.Paths.SourceDirectory; public IDirectoryInfo OutputDirectory => FileSystem.Paths.OutputDirectory; public IFileInfo ConfigurationPath => FileSystem.Paths.ConfigurationPath; diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs index d8a732e33d..a67938efa0 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs @@ -210,9 +210,9 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, var gitHubRepo = fileContext.BuildContext.Git.GitHubRepository; var branch = fileContext.BuildContext.Git.Branch; if (gitHubRepo is not null - && fileContext.BuildContext.Git != GitCheckoutInformation.Unavailable - && fileContext.BuildContext.DocumentationCheckoutDirectory is { } checkoutDirectory) + && fileContext.BuildContext.Git != GitCheckoutInformation.Unavailable) { + var checkoutDirectory = fileContext.BuildContext.DocumentationCheckoutDirectory; var relativeSourcePath = Path.GetRelativePath( checkoutDirectory.FullName, fileContext.BuildContext.DocumentationSourceDirectory.FullName); diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs index fb779c815f..f3a7c1b07b 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs @@ -164,7 +164,7 @@ BuildContext build private static IFileInfo GetRuleSourcePath(IFileInfo rulePath, BuildContext build) { - var checkoutDir = build.DocumentationCheckoutDirectory ?? build.DocumentationSourceDirectory.Parent!; + var checkoutDir = build.DocumentationCheckoutDirectory; var relative = Path.GetRelativePath(checkoutDir.FullName, rulePath.FullName); var newPath = Path.Join(build.DocumentationSourceDirectory.FullName, relative); var md = Path.ChangeExtension(newPath, ".md"); diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index c587fd11d7..72135f1680 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -94,8 +94,9 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc var gitHubRepo = DocumentationSet.Context.Git.GitHubRepository; var branch = DocumentationSet.Context.Git.Branch; string? editUrl = null; - if (gitHubRepo is not null && DocumentationSet.Context.Git != GitCheckoutInformation.Unavailable && DocumentationSet.Context.DocumentationCheckoutDirectory is { } checkoutDirectory) + if (gitHubRepo is not null && DocumentationSet.Context.Git != GitCheckoutInformation.Unavailable) { + var checkoutDirectory = DocumentationSet.Context.DocumentationCheckoutDirectory; var relativeSourcePath = Path.GetRelativePath(checkoutDirectory.FullName, DocumentationSet.Context.DocumentationSourceDirectory.FullName); var path = UrlPath.Join(relativeSourcePath, markdown.RelativePath); editUrl = $"https://github.com/{gitHubRepo}/edit/{branch}/{path}"; @@ -158,9 +159,9 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc var gitRef = DocumentationSet.Context.Git.Ref; string? gitHubDocsUrl = null; if (gitHubRepo is not null - && !string.IsNullOrEmpty(gitBranch) && gitBranch != "unavailable" - && DocumentationSet.Context.DocumentationCheckoutDirectory is { } docsCheckoutDir) + && !string.IsNullOrEmpty(gitBranch) && gitBranch != "unavailable") { + var docsCheckoutDir = DocumentationSet.Context.DocumentationCheckoutDirectory; var relativeDocsPath = Path.GetRelativePath(docsCheckoutDir.FullName, DocumentationSet.Context.DocumentationSourceDirectory.FullName) .Replace(Path.DirectorySeparatorChar, '/'); gitHubDocsUrl = $"https://github.com/{gitHubRepo}/tree/{gitBranch}/{relativeDocsPath}"; diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index e155040cc3..f935651cfa 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -91,7 +91,7 @@ public DocumentationSet( Name = Context.Git != GitCheckoutInformation.Unavailable ? Context.Git.RepositoryName - : Context.DocumentationCheckoutDirectory?.Name + : Context.DocumentationCheckoutDirectory.Name ?? Context.DocumentationSourceDirectory.Parent?.Name ?? Context.DocumentationSourceDirectory.Name; OutputStateFile = OutputDirectory.FileSystem.FileInfo.New(Path.Join(OutputDirectory.FullName, ".doc.state")); diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 97f2794f90..9f054ea8a4 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -404,7 +404,7 @@ private void LoadConfiguration() => /// against this same root. /// private IDirectoryInfo ConfigTrustRoot => - Build.DocumentationCheckoutDirectory ?? Build.DocumentationSourceDirectory; + Build.DocumentationCheckoutDirectory; private string? ResolveConfigPath() { diff --git a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs index 43776a17e4..1097b5723e 100644 --- a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs +++ b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs @@ -49,8 +49,7 @@ public async Task StartAsync(Cancel cancellationToken) _serviceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // Await the live-reload generator so the server can serve pages immediately. - var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory?.FullName - ?? ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName; + var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; await ReloadableGenerator.ReloadAsync(cancellationToken); // Start the build loop; only shutdownCt (Ctrl+C / app exit) can cancel a running build. @@ -58,13 +57,7 @@ public async Task StartAsync(Cancel cancellationToken) _backgroundBuildTask = InMemoryBuildState.RunAsync(_serviceCts.Token); InMemoryBuildState.ScheduleBuild(sourcePath); - // ReSharper disable once RedundantAssignment - var directory = ReloadableGenerator.Generator.DocumentationSet.SourceDirectory.FullName; -#if DEBUG - // Fall back to source directory when there is no separate checkout directory (e.g. when serving the project's own docs from a worktree) - directory = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory?.FullName - ?? ReloadableGenerator.Generator.DocumentationSet.SourceDirectory.FullName; -#endif + var directory = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; Logger.LogInformation("Start file watch on: {Directory}", directory); var watcher = new FileSystemWatcher(directory) { @@ -108,8 +101,7 @@ private void Reload(bool reloadConfiguration = false) // Schedule a validation build after every reload — both content edits and structural changes. // The build loop coalesces rapid triggers: a new request while a build runs queues one more. - var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory?.FullName - ?? ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName; + var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; InMemoryBuildState.ScheduleBuild(sourcePath); }, token); } From 579047bc377a642ecbc1316c2f1447eebebf9c87 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 15:05:18 +0200 Subject: [PATCH 22/29] Remove plain new FileSystem() intermediary variables at call sites DocumentationScopeOptions.Output, GitDir, and ConfigurationFile are now string? instead of IDirectoryInfo?/IFileInfo?. The resolver converts them internally using the inner filesystem, so callers never need a throwaway FileSystem() just to wrap a path. All existing call sites updated: FormatService, MoveFileService, LocalChangesService, AssemblerDocumentationSet, IsolatedBuildService, CodexBuildService, and test files. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Building/CodexBuildService.cs | 4 ++-- .../DocumentationPathsResolver.cs | 23 ++++++++----------- .../FileSystems/DocumentationFileSystem.cs | 9 ++++---- .../FormatService.cs | 4 +--- .../MoveFileService.cs | 4 +--- .../Tracking/LocalChangesService.cs | 4 +--- .../Navigation/AssemblerDocumentationSet.cs | 7 ++---- .../IsolatedBuildService.cs | 14 ++++------- .../OpenApiGeneratorMultiVersionTests.cs | 6 ++--- .../ApiConfigurationTests.cs | 5 ++-- .../DocumentationPathsResolverTests.cs | 8 +++---- .../BuildContextConfigurationFileTests.cs | 6 ++--- ...textDocumentationCheckoutDirectoryTests.cs | 6 ++--- .../TestDocumentationSetContext.cs | 2 +- .../Framework/CrossLinkResolverAssertions.fs | 2 +- 15 files changed, 43 insertions(+), 61 deletions(-) diff --git a/src/Elastic.Codex/Building/CodexBuildService.cs b/src/Elastic.Codex/Building/CodexBuildService.cs index 79f3bb78e2..ad361ed781 100644 --- a/src/Elastic.Codex/Building/CodexBuildService.cs +++ b/src/Elastic.Codex/Building/CodexBuildService.cs @@ -190,9 +190,9 @@ public async Task BuildAll( // one from the repository root and always landing on `docs/`. var docFs = DocumentationFileSystem.Resolve(checkout.RepositoryDirectory, new DocumentationScopeOptions { - Output = fileSystem.DirectoryInfo.New(outputPath), + Output = outputPath, Git = git, - ConfigurationFile = checkout.DocsetFile, + ConfigurationFile = checkout.DocsetFile.FullName, }); var buildContext = new BuildContext(context.Collector, docFs, configurationContext) { diff --git a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs index 0758d03bc4..2a54d003b2 100644 --- a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs +++ b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs @@ -91,17 +91,17 @@ public sealed record ResolvedDocumentationPaths public sealed record DocumentationScopeOptions { /// Explicit output directory (--output). - public IDirectoryInfo? Output { get; init; } + public string? Output { get; init; } /// /// Explicit --git-dir override — the .git directory; its .Parent is the checkout. /// Worktrees are handled automatically through the commondir path; do not point this at a /// worktree's internal gitdir (.git/worktrees/<name>). /// - public IDirectoryInfo? GitDir { get; init; } + public string? GitDir { get; init; } /// Pre-discovered docset configuration file. When set, the docset scan is skipped. - public IFileInfo? ConfigurationFile { get; init; } + public string? ConfigurationFile { get; init; } /// /// Git checkout information override (for tests). Replaces the GitCheckoutInformationFactory @@ -157,8 +157,8 @@ public static ResolvedDocumentationPaths Resolve( IFileSystem inner) { // 1-2. Anchor. Scoped to the invocation path only; skipped when the docset is already known. - var (source, configuration) = options.ConfigurationFile is { } known - ? (known.Directory!, known) + var (source, configuration) = options.ConfigurationFile is { } known && inner.NewFileInfo(known) is { Exists: true } configFile + ? (configFile.Directory!, configFile) : ScanForDocset(invocation, inner); // 3. Checkout, derived from the anchor — never from the invocation. @@ -170,7 +170,7 @@ public static ResolvedDocumentationPaths Resolve( // anchor's ancestry by design, so a scoped FS would block the commondir traversal. // When --git-dir is explicit the checkout is gitDir.Parent, so gitDir itself must be // carried forward — ResolveGitDirectories can't find it via the gitScope (out-of-tree). - var gitDirectories = options.GitDir is { } explicitGitDir + var gitDirectories = options.GitDir is { } defined && inner.NewDirInfo(defined) is { Exists: true } explicitGitDir ? [explicitGitDir.FullName] : ResolveGitDirectories(gitScope, checkout, inner); @@ -184,8 +184,7 @@ public static ResolvedDocumentationPaths Resolve( // 6. Output. Default is relative to the checkout, not the invocation. // --path repo/docs and --path repo/ must both write to repo/.artifacts, not repo/docs/.artifacts. - var output = options.Output ?? inner.DirectoryInfo.New( - inner.Path.Join(checkout.FullName, ".artifacts", "docs", "html")); + var output = inner.NewDirInfo(options.Output ?? inner.Path.Join(checkout.FullName, ".artifacts", "docs", "html")); // 7. Disjointness-filter the extra roots. var extraRoots = FilterExtraRoots(options.ExtraRoots, checkout); @@ -218,17 +217,15 @@ private static IDirectoryInfo ResolveCheckout( DocumentationScopeOptions options, IFileSystem inner) { - if (options.GitDir is { } explicitGitDir) + if (options.GitDir is { } configured && inner.NewDirInfo(configured) is { } explicitGitDir) { if (!inner.Directory.Exists(explicitGitDir.FullName)) - throw new DocumentationPathException( - $"--git-dir '{explicitGitDir.FullName}' does not exist."); + throw new DocumentationPathException($"--git-dir '{explicitGitDir.FullName}' does not exist."); if (!inner.File.Exists(inner.Path.Join(explicitGitDir.FullName, "HEAD"))) throw new DocumentationPathException( $"--git-dir '{explicitGitDir.FullName}' does not appear to be a valid .git directory (no HEAD file found)."); return explicitGitDir.Parent - ?? throw new DocumentationPathException( - $"--git-dir '{explicitGitDir.FullName}' has no parent directory."); + ?? throw new DocumentationPathException($"--git-dir '{explicitGitDir.FullName}' has no parent directory."); } var gitRoot = Paths.FindGitRoot(gitScope.DirectoryInfo.New(source.FullName), options.MaxParents); diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs index 2e4fa1fea7..e3738dcc8f 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs @@ -58,9 +58,7 @@ private DocumentationFileSystem(ResolvedDocumentationPaths paths, IFileSystem in /// No docset found under , or no .git within MaxParents of the /// anchor and no --git-dir override. /// - public static DocumentationFileSystem Resolve( - IDirectoryInfo? path = null, - DocumentationScopeOptions? options = null) + public static DocumentationFileSystem Resolve(IDirectoryInfo? path = null, DocumentationScopeOptions? options = null) { var opts = options ?? new DocumentationScopeOptions(); var inner = opts.Inner ?? Physical; @@ -72,11 +70,12 @@ public static DocumentationFileSystem Resolve( return new DocumentationFileSystem(paths, inner, opts.InnerWrite); } - public static DocumentationFileSystem Resolve(string path, DocumentationScopeOptions? options = null) + public static DocumentationFileSystem Resolve(string? path, DocumentationScopeOptions? options = null) { var opts = options ?? new DocumentationScopeOptions(); var inner = opts.Inner ?? Physical; - return Resolve(inner.DirectoryInfo.New(path), opts); + var invocation = path is not null ? inner.DirectoryInfo.New(path) : null; + return Resolve(invocation, opts); } private static ScopedFileSystemOptions BuildReadOptions(ResolvedDocumentationPaths paths) diff --git a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs index cc5c28ab0c..ef8d6d4e84 100644 --- a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs @@ -41,9 +41,7 @@ Cancel ctx ) { // Create BuildContext to load the documentation set - var plain = new FileSystem(); - var invocation = path is not null ? plain.DirectoryInfo.New(path) : null; - var docFs = DocumentationFileSystem.Resolve(invocation); + var docFs = DocumentationFileSystem.Resolve(path); var context = new BuildContext(collector, docFs, configurationContext) { AvailableExporters = ExportOptions.MetadataOnly }; var set = new DocumentationSet(context, logFactory, NoopCrossLinkResolver.Instance); diff --git a/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs b/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs index 644293c57a..2c037d0104 100644 --- a/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs @@ -29,9 +29,7 @@ public async Task Move( Cancel ctx ) { - var plain = new FileSystem(); - var invocation = path is not null ? plain.DirectoryInfo.New(path) : null; - var docFs = DocumentationFileSystem.Resolve(invocation); + var docFs = DocumentationFileSystem.Resolve(path); var context = new BuildContext(collector, docFs, configurationContext) { AvailableExporters = ExportOptions.MetadataOnly }; var set = new DocumentationSet(context, logFactory, NoopCrossLinkResolver.Instance); diff --git a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs index 051a4e9323..1882bf3f14 100644 --- a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs @@ -25,9 +25,7 @@ public Task ValidateRedirects(IDiagnosticsCollector collector, string? pat { var runningOnCi = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); - var plain = new FileSystem(); - var invocation = path is not null ? plain.DirectoryInfo.New(path) : null; - var docFs = DocumentationFileSystem.Resolve(invocation); + var docFs = DocumentationFileSystem.Resolve(path); var buildContext = new BuildContext(collector, docFs, configurationContext) { AvailableExporters = ExportOptions.MetadataOnly }; var redirectFile = new RedirectFile(buildContext); if (!redirectFile.Source.Exists) diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs index 0b38498c4e..31f1eda6a2 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs @@ -53,12 +53,9 @@ IReadOnlySet availableExporters Branch = checkout.Repository.GetBranch(env.ContentSource) }; - var plain = new FileSystem(); - var invocationDir = plain.DirectoryInfo.New(path); - var outputDir = plain.DirectoryInfo.New(output); - var docFs = DocumentationFileSystem.Resolve(invocationDir, new DocumentationScopeOptions + var docFs = DocumentationFileSystem.Resolve(path, new DocumentationScopeOptions { - Output = outputDir, + Output = output, Git = gitConfiguration, }); var buildContext = new BuildContext(context.Collector, docFs, configurationContext) diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index 442976e3a9..edc005d83a 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -83,15 +83,12 @@ public async Task Build( force = true; } - var plain = new FileSystem(); - var invocation = path is not null ? plain.DirectoryInfo.New(path) : null; - var outputDir = options.Output is not null ? plain.DirectoryInfo.New(options.Output.FullName) : null; try { - var docFs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions + var docFs = DocumentationFileSystem.Resolve(path, new DocumentationScopeOptions { - Output = outputDir, - InnerWrite = writeFileSystem, + Output = options.Output?.FullName, + InnerWrite = writeFileSystem }); context = new BuildContext(collector, docFs, configurationContext) { @@ -110,9 +107,8 @@ public async Task Build( // Derive the default output from `path` so it stays within the write FS scope. // Using Paths.WorkingDirectoryRoot would be wrong when --path points to a different repo. var rootFolder = !string.IsNullOrWhiteSpace(path) ? path : Paths.WorkingDirectoryRoot.FullName; - var fallbackFs = writeFileSystem ?? plain; - var outputDirectory = outputDir - ?? fallbackFs.DirectoryInfo.New(Path.Join(rootFolder, ".artifacts/docs/html")); + var fallbackFs = writeFileSystem ?? new FileSystem(); + var outputDirectory = fallbackFs.DirectoryInfo.New(output ?? Path.Join(rootFolder, ".artifacts/docs/html")); // we temporarily do not error when pointed to a non-documentation folder. _ = fallbackFs.Directory.CreateDirectory(outputDirectory.FullName); diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs index faba5eb973..7eff1c5823 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs @@ -220,10 +220,10 @@ private static BuildContext CreateGenerateContext( var configurationContext = TestHelpers.CreateConfigurationContext(fs, versionsConfiguration, productsConfiguration); return new BuildContext(collector, - DocumentationFileSystem.Resolve(fs.DirectoryInfo.New(repoRoot), new DocumentationScopeOptions + DocumentationFileSystem.Resolve(repoRoot, new DocumentationScopeOptions { - ConfigurationFile = fs.FileInfo.New(configPath), - Output = fs.DirectoryInfo.New(outputRoot), + ConfigurationFile = configPath, + Output = outputRoot, Git = git, Inner = fs }), diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index 8739428e40..14bb6a8923 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs @@ -641,9 +641,8 @@ private sealed class MockDocumentationSetContext( public IDiagnosticsCollector Collector => collector; public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve( documentationSourceDirectory, - new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configurationPath }); - public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( - fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); + new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configurationPath.FullName }); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs index 94ca95c091..f11a0570ad 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs @@ -275,7 +275,7 @@ public void ExplicitGitDir_CheckoutIsGitDirParent() var opts = new DocumentationScopeOptions { Inner = fs, - GitDir = fs.DirectoryInfo.New("/repo/.git") + GitDir = "/repo/.git" }; var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project/docs"), opts, fs); @@ -303,7 +303,7 @@ public void ExplicitGitDir_GitInfo_ResolvedFromOverriddenGitDir() var opts = new DocumentationScopeOptions { Inner = fs, - GitDir = fs.DirectoryInfo.New("/repo/.git") + GitDir = "/repo/.git" }; var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project/docs"), opts, fs); @@ -383,7 +383,7 @@ public void Output_ExplicitOverride_IsRespected() var opts = new DocumentationScopeOptions { Inner = fs, - Output = fs.DirectoryInfo.New("/custom/output") + Output = "/custom/output" }; var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); @@ -402,7 +402,7 @@ public void PreDiscoveredConfigFile_SkipsDocsetScan() var fs = RegularRepo(repoRoot: "/project"); var docsetFile = fs.FileInfo.New("/project/docs/docset.yml"); - var opts = new DocumentationScopeOptions { Inner = fs, ConfigurationFile = docsetFile }; + var opts = new DocumentationScopeOptions { Inner = fs, ConfigurationFile = docsetFile.FullName }; var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project"), opts, fs); paths.SourceDirectory.FullName.Should().Be(P(fs, "/project/docs")); diff --git a/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs b/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs index f654ad9f31..fa708156ce 100644 --- a/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs +++ b/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs @@ -41,8 +41,8 @@ public void ExplicitConfigurationFile_OverridesDefaultDiscovery() new DocumentationScopeOptions { Inner = fs, - ConfigurationFile = fs.FileInfo.New(internalDocsetPath), - Output = fs.DirectoryInfo.New(Path.Combine(root, "codex-configuration-file-test-out")) + ConfigurationFile = internalDocsetPath, + Output = Path.Join(root, "codex-configuration-file-test-out") }); var context = new BuildContext(collector, docFs, configurationContext); @@ -73,7 +73,7 @@ public void NoExplicitConfigurationFile_FallsBackToDefaultDiscovery() new DocumentationScopeOptions { Inner = fs, - Output = fs.DirectoryInfo.New(Path.Combine(root, "codex-configuration-file-fallback-test-out")) + Output = Path.Join(root, "codex-configuration-file-fallback-test-out") }); var context = new BuildContext(collector, docFs, configurationContext); diff --git a/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs b/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs index 646a16c82c..a69f2a8ba1 100644 --- a/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs +++ b/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs @@ -44,7 +44,7 @@ public void SourceAsRepositoryRoot_SetsDocumentationCheckoutDirectory() new DocumentationScopeOptions { Inner = fs, - Output = fs.DirectoryInfo.New(Path.Combine(root, "codex-checkout-dir-test-out")) + Output = Path.Join(root, "codex-checkout-dir-test-out") }); var context = new BuildContext(collector, docFs, configurationContext); @@ -70,7 +70,7 @@ public void SourceAsDocsSubtree_ResolvesCheckoutFromParent() new DocumentationScopeOptions { Inner = fs, - Output = fs.DirectoryInfo.New(Path.Combine(root, "codex-docs-only-test-out")) + Output = Path.Join(root, "codex-docs-only-test-out") }); var context = new BuildContext(collector, docFs, configurationContext); @@ -96,7 +96,7 @@ public void PathAndDocsSubfolder_ResolveIdenticalCheckout() var opts = new DocumentationScopeOptions { Inner = fs, - Output = fs.DirectoryInfo.New(Path.Combine(root, "codex-equiv-test-out")) + Output = Path.Combine(root, "codex-equiv-test-out") }; var fsFromRepoRoot = DocumentationFileSystem.Resolve(fs.DirectoryInfo.New(repoPath), opts); diff --git a/tests/Navigation.Tests/TestDocumentationSetContext.cs b/tests/Navigation.Tests/TestDocumentationSetContext.cs index 330e911bd3..b7c299cd00 100644 --- a/tests/Navigation.Tests/TestDocumentationSetContext.cs +++ b/tests/Navigation.Tests/TestDocumentationSetContext.cs @@ -83,7 +83,7 @@ public TestDocumentationSetContext(IFileSystem fileSystem, TestDiagnosticsCollector? collector = null ) { - ReadFileSystem = DocumentationFileSystem.Resolve(sourceDirectory, new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configPath }); + ReadFileSystem = DocumentationFileSystem.Resolve(sourceDirectory, new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configPath.FullName }); WriteFileSystem = new DocumentationWriteFileSystem(sourceDirectory, outputDirectory, fileSystem); DocumentationSourceDirectory = sourceDirectory; OutputDirectory = outputDirectory; diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs index ce3811f814..7d58587158 100644 --- a/tests/authoring/Framework/CrossLinkResolverAssertions.fs +++ b/tests/authoring/Framework/CrossLinkResolverAssertions.fs @@ -33,7 +33,7 @@ module CrossLinkResolverAssertions = member _.Collector = collector member _.DocumentationSourceDirectory = mockFileSystem.DirectoryInfo.New("/docs") member _.Git = GitCheckoutInformation.Unavailable - member _.ReadFileSystem = DocumentationFileSystem.Resolve(mockFileSystem.DirectoryInfo.New("/docs"), DocumentationScopeOptions(Inner = (mockFileSystem :> IFileSystem), ConfigurationFile = mockFileSystem.FileInfo.New("/docs/docset.yml"))) :> IDocumentationFileSystem + member _.ReadFileSystem = DocumentationFileSystem.Resolve(mockFileSystem.DirectoryInfo.New("/docs"), DocumentationScopeOptions(Inner = (mockFileSystem :> IFileSystem), ConfigurationFile = "/docs/docset.yml")) :> IDocumentationFileSystem member _.WriteFileSystem = DocumentationWriteFileSystem(mockFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, mockFileSystem) member _.ConfigurationPath = mockFileSystem.FileInfo.New("mock_docset.yml") member _.OutputDirectory = mockFileSystem.DirectoryInfo.New(".artifacts") From 25953f8fe5739fe3e07e8d23e01b0758bdb9fee3 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 15:13:42 +0200 Subject: [PATCH 23/29] Add CodexFileSystem; eliminate plain new FileSystem() in codex commands CodexFileSystem extends CheckoutsFileSystem, rooted at the working directory with the config file's git root as an extra allowed root. It exposes ConfigurationFile so callers never need a throwaway filesystem just to wrap the config path. All codex commands (clone-and-build, clone, build, update-redirects, index, sync) now construct a single CodexFileSystem instead of the four-line plain/gitRoot/CheckoutsFileSystem/configFile pattern. DocumentationWebHost uses the existing string? overload of DocumentationFileSystem.Resolve, dropping its own plain intermediary. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Elastic.Codex/CodexFileSystem.cs | 42 +++++++++++++++++++ .../Commands/Codex/CodexCommands.cs | 38 ++++------------- .../Commands/Codex/CodexIndexCommand.cs | 11 ++--- .../Commands/Codex/CodexSyncCommand.cs | 11 ++--- .../Codex/CodexUpdateRedirectsCommand.cs | 9 +--- .../docs-builder/Http/DocumentationWebHost.cs | 4 +- 6 files changed, 60 insertions(+), 55 deletions(-) create mode 100644 src/Elastic.Codex/CodexFileSystem.cs diff --git a/src/Elastic.Codex/CodexFileSystem.cs b/src/Elastic.Codex/CodexFileSystem.cs new file mode 100644 index 0000000000..4a71ed6103 --- /dev/null +++ b/src/Elastic.Codex/CodexFileSystem.cs @@ -0,0 +1,42 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; + +namespace Elastic.Codex; + +/// +/// Scope over the working directory for codex operations. Rooted at the process working directory +/// with the config file's git root added as an extra allowed root, so the config file and any +/// paths inside its repository are always readable. +/// +/// Use this in codex commands instead of constructing a manually. +/// +/// +public class CodexFileSystem : CheckoutsFileSystem +{ + private static readonly FileSystem Physical = new(); + + /// The codex configuration file, resolved through this scoped filesystem. + public IFileInfo ConfigurationFile { get; } + + /// The codex configuration file. Its directory is used to locate the git root. + /// Optional explicit output directory. + /// Underlying filesystem — defaults to the physical filesystem when . + public CodexFileSystem(FileInfo config, DirectoryInfo? output = null, IFileSystem? inner = null) + : this(inner ?? Physical, config, output, inner) + { } + + private CodexFileSystem(IFileSystem fs, FileInfo config, DirectoryInfo? output, IFileSystem? inner) + : base( + root: fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + output: output is not null ? fs.DirectoryInfo.New(output.FullName) : null, + inner: inner, + extraRoots: [Paths.FindGitRoot(fs.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]) + { + ConfigurationFile = this.FileInfo.New(config.FullName); + } +} diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 97bc178849..3ca45a45a5 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -59,18 +59,11 @@ public async Task CloneAndBuild( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var plain = new FileSystem(); - var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; - var fs = new CheckoutsFileSystem( - plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - output is null ? null : plain.DirectoryInfo.New(output.FullName), - inner: plain, extraRoots: [gitRoot]); - - var configFile = fs.FileInfo.New(config.FullName); - if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) + var fs = new CodexFileSystem(config, output); + if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, fs, null, output?.FullName); + var codexContext = new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs, null, output?.FullName); using var linkIndexReader = new GitLinkIndexReader(environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); @@ -121,17 +114,11 @@ public async Task Clone( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var plain = new FileSystem(); - var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; - var fs = new CheckoutsFileSystem( - plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - inner: plain, extraRoots: [gitRoot]); - - var configFile = fs.FileInfo.New(config.FullName); - if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) + var fs = new CodexFileSystem(config); + if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, fs); + var codexContext = new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs); using var linkIndexReader = new GitLinkIndexReader(environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); @@ -159,18 +146,11 @@ public async Task Build( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var plain = new FileSystem(); - var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; - var fs = new CheckoutsFileSystem( - plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - output is null ? null : plain.DirectoryInfo.New(output.FullName), - inner: plain, extraRoots: [gitRoot]); - - var configFile = fs.FileInfo.New(config.FullName); - if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out _)) + var fs = new CodexFileSystem(config, output); + if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out _)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, fs, null, output?.FullName); + var codexContext = new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs, null, output?.FullName); var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); if (cloneResult == null || cloneResult.Checkouts.Count == 0) diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index cf2c757e02..35643c8326 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -44,16 +44,11 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var plain = new FileSystem(); - var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; - var fs = new CheckoutsFileSystem( - plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - inner: plain, extraRoots: [gitRoot]); - var configFile = fs.FileInfo.New(config.FullName); - if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) + var fs = new CodexFileSystem(config); + if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, fs); + var codexContext = new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs); var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs index 6814fe6e41..cb153daf47 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs @@ -87,14 +87,9 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex private (CodexContext context, IncrementalDeployService service) LoadContext(FileInfo config) { - var plain = new FileSystem(); - var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; - var fs = new CheckoutsFileSystem( - plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - inner: plain, extraRoots: [gitRoot]); - var configFile = fs.FileInfo.New(config.FullName); - var codexConfig = CodexConfiguration.Load(configFile); - return (new CodexContext(codexConfig, configFile, collector, fs), + var fs = new CodexFileSystem(config); + var codexConfig = CodexConfiguration.Load(fs.ConfigurationFile); + return (new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs), new IncrementalDeployService(logFactory, githubActionsService)); } } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index fd216f3319..c63e325de2 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -36,13 +36,8 @@ public async Task UpdateRedirects( { await using var serviceInvoker = new ServiceInvoker(collector); - var plain = new FileSystem(); - var gitRoot = Paths.FindGitRoot(plain.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!; - var fs = new CheckoutsFileSystem( - plain.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - inner: plain, extraRoots: [gitRoot]); - var configFile = fs.FileInfo.New(config.FullName); - if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig)) + var fs = new CodexFileSystem(config); + if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig)) return 1; var resolvedEnvironment = environment diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 085c9b8168..2dee21bcc7 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -70,9 +70,7 @@ bool isWatchBuild var hostUrl = $"http://localhost:{port}"; _hostedService = collector; - var plain = new FileSystem(); - var invocation = path is not null ? plain.DirectoryInfo.New(path) : null; - var docFs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { InnerWrite = new MockFileSystem() }); + var docFs = DocumentationFileSystem.Resolve(path, new DocumentationScopeOptions { InnerWrite = new MockFileSystem() }); _writeFileSystem = docFs.Write; Context = new BuildContext(collector, docFs, configurationContext) { From 3b2b2b3bdaee814a68b066e5b2d759fb672b41b2 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 15:17:34 +0200 Subject: [PATCH 24/29] Tweak CodexFileSystem: output as string?, expression-body init Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Elastic.Codex/CodexFileSystem.cs | 13 ++++++------- .../docs-builder/Commands/Codex/CodexCommands.cs | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Elastic.Codex/CodexFileSystem.cs b/src/Elastic.Codex/CodexFileSystem.cs index 4a71ed6103..fc3c35f0b5 100644 --- a/src/Elastic.Codex/CodexFileSystem.cs +++ b/src/Elastic.Codex/CodexFileSystem.cs @@ -26,17 +26,16 @@ public class CodexFileSystem : CheckoutsFileSystem /// The codex configuration file. Its directory is used to locate the git root. /// Optional explicit output directory. /// Underlying filesystem — defaults to the physical filesystem when . - public CodexFileSystem(FileInfo config, DirectoryInfo? output = null, IFileSystem? inner = null) + public CodexFileSystem(FileInfo config, string? output = null, IFileSystem? inner = null) : this(inner ?? Physical, config, output, inner) { } - private CodexFileSystem(IFileSystem fs, FileInfo config, DirectoryInfo? output, IFileSystem? inner) + private CodexFileSystem(IFileSystem fs, FileInfo config, string? output, IFileSystem? inner) : base( root: fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - output: output is not null ? fs.DirectoryInfo.New(output.FullName) : null, + output: output is not null ? fs.DirectoryInfo.New(output) : null, inner: inner, - extraRoots: [Paths.FindGitRoot(fs.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!]) - { - ConfigurationFile = this.FileInfo.New(config.FullName); - } + extraRoots: [Paths.FindGitRoot(fs.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!] + ) + => ConfigurationFile = FileInfo.New(config.FullName); } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 3ca45a45a5..ac3f70f2ae 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -59,7 +59,7 @@ public async Task CloneAndBuild( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new CodexFileSystem(config, output); + var fs = new CodexFileSystem(config, output?.FullName); if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; @@ -146,7 +146,7 @@ public async Task Build( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new CodexFileSystem(config, output); + var fs = new CodexFileSystem(config, output?.FullName); if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out _)) return 1; From 128e08593a5dee1cd9b71fab8795b9c74beceaaf Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 15:19:57 +0200 Subject: [PATCH 25/29] CodexFileSystem takes string config instead of FileInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistent with string? output — callers pass config.FullName directly. Path.GetDirectoryName used internally for git-root discovery. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Elastic.Codex/CodexFileSystem.cs | 10 +++++----- .../docs-builder/Commands/Codex/CodexCommands.cs | 6 +++--- .../docs-builder/Commands/Codex/CodexIndexCommand.cs | 2 +- .../docs-builder/Commands/Codex/CodexSyncCommand.cs | 2 +- .../Commands/Codex/CodexUpdateRedirectsCommand.cs | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Elastic.Codex/CodexFileSystem.cs b/src/Elastic.Codex/CodexFileSystem.cs index fc3c35f0b5..4e1b16e8bf 100644 --- a/src/Elastic.Codex/CodexFileSystem.cs +++ b/src/Elastic.Codex/CodexFileSystem.cs @@ -23,19 +23,19 @@ public class CodexFileSystem : CheckoutsFileSystem /// The codex configuration file, resolved through this scoped filesystem. public IFileInfo ConfigurationFile { get; } - /// The codex configuration file. Its directory is used to locate the git root. + /// Full path to the codex configuration file. Its directory is used to locate the git root. /// Optional explicit output directory. /// Underlying filesystem — defaults to the physical filesystem when . - public CodexFileSystem(FileInfo config, string? output = null, IFileSystem? inner = null) + public CodexFileSystem(string config, string? output = null, IFileSystem? inner = null) : this(inner ?? Physical, config, output, inner) { } - private CodexFileSystem(IFileSystem fs, FileInfo config, string? output, IFileSystem? inner) + private CodexFileSystem(IFileSystem fs, string config, string? output, IFileSystem? inner) : base( root: fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), output: output is not null ? fs.DirectoryInfo.New(output) : null, inner: inner, - extraRoots: [Paths.FindGitRoot(fs.DirectoryInfo.New(config.DirectoryName!))?.FullName ?? config.DirectoryName!] + extraRoots: [Paths.FindGitRoot(fs.DirectoryInfo.New(fs.Path.GetDirectoryName(config)!))?.FullName ?? fs.Path.GetDirectoryName(config)!] ) - => ConfigurationFile = FileInfo.New(config.FullName); + => ConfigurationFile = FileInfo.New(config); } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index ac3f70f2ae..1bc343111f 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -59,7 +59,7 @@ public async Task CloneAndBuild( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new CodexFileSystem(config, output?.FullName); + var fs = new CodexFileSystem(config.FullName, output?.FullName); if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; @@ -114,7 +114,7 @@ public async Task Clone( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new CodexFileSystem(config); + var fs = new CodexFileSystem(config.FullName); if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; @@ -146,7 +146,7 @@ public async Task Build( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new CodexFileSystem(config, output?.FullName); + var fs = new CodexFileSystem(config.FullName, output?.FullName); if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out _)) return 1; diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 35643c8326..95cb649d94 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -44,7 +44,7 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new CodexFileSystem(config); + var fs = new CodexFileSystem(config.FullName); if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; diff --git a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs index cb153daf47..ad319adf44 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs @@ -87,7 +87,7 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex private (CodexContext context, IncrementalDeployService service) LoadContext(FileInfo config) { - var fs = new CodexFileSystem(config); + var fs = new CodexFileSystem(config.FullName); var codexConfig = CodexConfiguration.Load(fs.ConfigurationFile); return (new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs), new IncrementalDeployService(logFactory, githubActionsService)); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index c63e325de2..048dd20595 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -36,7 +36,7 @@ public async Task UpdateRedirects( { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new CodexFileSystem(config); + var fs = new CodexFileSystem(config.FullName); if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig)) return 1; From e5512833a50750810d9d4713f1c3301f9824b0dc Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 15:46:16 +0200 Subject: [PATCH 26/29] Fix import ordering and expression-body style (dotnet format) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../ConfigurationFileProvider.cs | 2 +- src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs | 5 +---- .../Bundling/ChangelogBundleAmendService.cs | 2 +- .../Elastic.Changelog/Bundling/ChangelogBundlingService.cs | 2 +- .../Elastic.Changelog/Bundling/ChangelogRemoveService.cs | 2 +- .../Elastic.Changelog/Bundling/ProfileFilterResolver.cs | 2 +- .../Elastic.Changelog/Bundling/PromotionReportParser.cs | 2 +- .../Elastic.Changelog/Creation/ChangelogCreationService.cs | 2 +- .../Evaluation/ChangelogArtifactEvaluationService.cs | 3 +-- .../Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs | 2 +- .../Evaluation/ChangelogPrEvaluationService.cs | 2 +- .../Evaluation/ChangelogPrepareArtifactService.cs | 3 +-- .../GithubRelease/GitHubReleaseChangelogService.cs | 2 +- .../Elastic.Changelog/Rendering/ChangelogRenderer.cs | 2 +- .../Elastic.Changelog/Rendering/ChangelogRenderingService.cs | 2 +- .../Rendering/Markdown/BreakingChangesMarkdownRenderer.cs | 2 +- .../Rendering/Markdown/ChangelogGfmRenderer.cs | 2 +- .../Rendering/Markdown/DeprecationsMarkdownRenderer.cs | 2 +- .../Rendering/Markdown/HighlightsMarkdownRenderer.cs | 2 +- .../Rendering/Markdown/IndexMarkdownRenderer.cs | 2 +- .../Rendering/Markdown/KnownIssuesMarkdownRenderer.cs | 2 +- .../Rendering/Markdown/MarkdownRendererBase.cs | 2 +- .../Elastic.Changelog/Uploading/ChangelogUploadService.cs | 2 +- src/tooling/docs-builder/Commands/ChangelogCommand.cs | 2 +- src/tooling/docs-builder/Commands/DiffCommands.cs | 2 +- src/tooling/docs-builder/Commands/InboundLinkCommands.cs | 2 +- src/tooling/docs-builder/Commands/IndexCommand.cs | 2 +- src/tooling/docs-builder/Commands/MoveCommand.cs | 2 +- src/tooling/docs-builder/Http/ReloadableGeneratorState.cs | 2 +- .../Search.IntegrationTests/SearchRelevanceTests.cs | 2 +- .../VersionInferenceTests.cs | 2 +- .../Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs | 2 +- .../Navigation.Tests/Assembler/IdentifierCollectionTests.cs | 2 +- .../Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs | 2 +- tests/Navigation.Tests/Assembler/SiteNavigationTests.cs | 2 +- tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs | 2 +- 36 files changed, 36 insertions(+), 41 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index 7ccf52d046..fe7a508013 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -5,10 +5,10 @@ using System.IO.Abstractions; using System.Text.RegularExpressions; using Elastic.Documentation.Configuration.Assembler; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.Configuration.Converters; using Elastic.Documentation.Configuration.Serialization; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using YamlDotNet.Serialization; diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs index 681b7189ed..771a533a39 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs @@ -10,10 +10,7 @@ namespace Elastic.Markdown.Myst.Directives.CsvInclude; public static class CsvReader { - public static IEnumerable ReadCsvFile(string filePath, string separator, IFileSystem fileSystem) - { - return ReadWithSep(filePath, separator, fileSystem); - } + public static IEnumerable ReadCsvFile(string filePath, string separator, IFileSystem fileSystem) => ReadWithSep(filePath, separator, fileSystem); private static IEnumerable ReadWithSep(string filePath, string separator, IFileSystem fileSystem) { diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index 1baea69864..6cdd7f54ff 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -13,10 +13,10 @@ using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Bundling; diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index e71a45c6fa..4e3b58e071 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -15,10 +15,10 @@ using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Bundling; diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index 44c3bfe071..a39a9c4a8b 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -8,9 +8,9 @@ using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Bundling; diff --git a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs index ef8242972b..004fb1b175 100644 --- a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs @@ -8,10 +8,10 @@ using Elastic.Changelog.GitHub; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Bundling; diff --git a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs index 11864f029c..9a2c03e1c0 100644 --- a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs +++ b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs @@ -7,8 +7,8 @@ using System.Text.RegularExpressions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; -using Microsoft.Extensions.Logging; using Elastic.Documentation.FileSystems; +using Microsoft.Extensions.Logging; namespace Elastic.Changelog.Bundling; diff --git a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs index 25406d6567..7a435eb8d6 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs @@ -9,9 +9,9 @@ using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.Inference; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Creation; diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs index faec8d5d45..7ea408b3ca 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -2,8 +2,6 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Documentation.FileSystems; - using System.Globalization; using System.IO.Abstractions; using System.Text.Json; @@ -12,6 +10,7 @@ using Elastic.Changelog.GitHub; using Elastic.Changelog.Utilities; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs index 15aa7c6aee..5a33e224fb 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs @@ -3,11 +3,11 @@ // See the LICENSE file in the project root for more information using System.Buffers; -using Elastic.Documentation.FileSystems; using System.IO.Abstractions; using System.Security; using System.Text; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Evaluation; diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index b4e148f2aa..b572df850a 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -11,10 +11,10 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Evaluation; diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs index 1586e076ee..0773c20ce6 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs @@ -2,8 +2,6 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Documentation.FileSystems; - using System.IO.Abstractions; using System.Text; using System.Text.Json; @@ -12,6 +10,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index ffe5bd6a9f..99cb0750ba 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -13,10 +13,10 @@ using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.GithubRelease; diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs index 41c6ea4b66..64281dedba 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs @@ -5,8 +5,8 @@ using System.IO.Abstractions; using Elastic.Changelog.Rendering.Asciidoc; using Elastic.Changelog.Rendering.Markdown; -using Microsoft.Extensions.Logging; using Elastic.Documentation.FileSystems; +using Microsoft.Extensions.Logging; namespace Elastic.Changelog.Rendering; diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 07d354b56f..b647abe7e4 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -9,12 +9,12 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging; using NetEscapades.EnumGenerators; -using Elastic.Documentation.FileSystems; using YamlDotNet.Core; namespace Elastic.Changelog.Rendering; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index 48559e396c..46a3a235ff 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -5,8 +5,8 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation; -using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.FileSystems; +using Elastic.Documentation.ReleaseNotes; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs index 7bfe6e34ac..a518d4d092 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.IO.Abstractions; using System.Text; -using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.FileSystems; +using Elastic.Documentation.ReleaseNotes; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index 3a631e6404..55c98cfe92 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -4,8 +4,8 @@ using System.IO.Abstractions; using System.Text; -using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.FileSystems; +using Elastic.Documentation.ReleaseNotes; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index 8414c8f03f..404ffd3864 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -4,8 +4,8 @@ using System.IO.Abstractions; using System.Text; -using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.FileSystems; +using Elastic.Documentation.ReleaseNotes; using static System.Globalization.CultureInfo; namespace Elastic.Changelog.Rendering.Markdown; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 851ee5b006..dea2a32d79 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.IO.Abstractions; using System.Text; -using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.FileSystems; +using Elastic.Documentation.ReleaseNotes; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index 72a3bb9e35..1872a78c6a 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -4,8 +4,8 @@ using System.IO.Abstractions; using System.Text; -using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.FileSystems; +using Elastic.Documentation.ReleaseNotes; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index 8d50f17ee6..c9b9529330 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.IO.Abstractions; using System.Text; -using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.FileSystems; +using Elastic.Documentation.ReleaseNotes; namespace Elastic.Changelog.Rendering.Markdown; diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index 1a1d124027..41f292f921 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -9,10 +9,10 @@ using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Integrations.S3; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Uploading; diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index f5e3860877..d9e2e4b04d 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -23,8 +23,8 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; diff --git a/src/tooling/docs-builder/Commands/DiffCommands.cs b/src/tooling/docs-builder/Commands/DiffCommands.cs index 2077dded77..6a7f0d216f 100644 --- a/src/tooling/docs-builder/Commands/DiffCommands.cs +++ b/src/tooling/docs-builder/Commands/DiffCommands.cs @@ -5,8 +5,8 @@ using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Configuration; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Refactor.Tracking; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; diff --git a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs index 8243df58bb..0429771102 100644 --- a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs +++ b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs @@ -6,8 +6,8 @@ using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Configuration; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links.InboundLinks; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; diff --git a/src/tooling/docs-builder/Commands/IndexCommand.cs b/src/tooling/docs-builder/Commands/IndexCommand.cs index 396d2ec31d..35bfad9f4d 100644 --- a/src/tooling/docs-builder/Commands/IndexCommand.cs +++ b/src/tooling/docs-builder/Commands/IndexCommand.cs @@ -5,8 +5,8 @@ using Actions.Core.Services; using Elastic.Documentation; using Elastic.Documentation.Configuration; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Isolated; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; diff --git a/src/tooling/docs-builder/Commands/MoveCommand.cs b/src/tooling/docs-builder/Commands/MoveCommand.cs index dda54d00a4..a347fc2f27 100644 --- a/src/tooling/docs-builder/Commands/MoveCommand.cs +++ b/src/tooling/docs-builder/Commands/MoveCommand.cs @@ -5,8 +5,8 @@ using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Configuration; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Refactor; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 7edcd956cb..7e667d6600 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -3,11 +3,11 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; using Elastic.ApiExplorer; -using Elastic.Documentation.FileSystems; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown; diff --git a/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs b/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs index 19bc62be74..d448f4559e 100644 --- a/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs +++ b/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs @@ -5,10 +5,10 @@ using System.Globalization; using System.IO.Abstractions; using AwesomeAssertions; -using Elastic.Documentation.FileSystems; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Search; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Search; using Elastic.Documentation.Search.Common; using Elastic.Documentation.Search.Contract; diff --git a/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs b/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs index df8a2b10d2..f3aefa5047 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs @@ -5,11 +5,11 @@ using System.Collections.Frozen; using System.IO.Abstractions; using AwesomeAssertions; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration.Inference; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging.Abstractions; diff --git a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs index 7772a1d24e..fca9bab7b9 100644 --- a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs @@ -4,9 +4,9 @@ using AwesomeAssertions; using Elastic.Documentation.Assembler.Navigation; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Leaf; diff --git a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs index 5968e3043f..8be34fddb3 100644 --- a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs +++ b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs @@ -4,8 +4,8 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Node; diff --git a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs index 6b5a129be6..2861cdc71c 100644 --- a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs @@ -4,8 +4,8 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Leaf; diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs index 3f31fc6494..b82724fffc 100644 --- a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs @@ -5,8 +5,8 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Configuration; -using Elastic.Documentation.FileSystems; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Node; diff --git a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs index 9c4d59da4b..811ad530fe 100644 --- a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs +++ b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs @@ -9,9 +9,9 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Isolated.Leaf; -using Nullean.ScopedFileSystem; using Elastic.Documentation.Navigation.Isolated.Node; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests.Isolation; From 0a4b2ac681c2dfe2cca8391a9b69ea1e744dd7a1 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 15:58:03 +0200 Subject: [PATCH 27/29] Merge main; fix new BundleFilesFilterTests call site argument order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New test added in main still used old ChangelogBundlingService argument order (ConfigurationContext, FileSystem). Our branch swapped them to (FileSystem, ConfigurationContext) — fix the new call site to match. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Changelogs/BundleFilesFilterTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs index 66707395b5..7ba1ab2e2e 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs @@ -415,7 +415,7 @@ public async Task Bundle_WithProfile_PathListFile_RepoResolves_SourcesFromCdn() private ChangelogBundlingService ServiceWithCdn(StubHandler handler) { var fetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); - return new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, fetcher); + return new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, fetcher); } private async Task WriteRepoOnlyConfigAsync() From 23d76e5bf43ca77f8a8783d9f09c742b47c06024 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 11 Aug 2026 16:44:36 +0200 Subject: [PATCH 28/29] Drop Exists check on ConfigurationFile in resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores parity with old IFileInfo-based behavior: when a caller supplies ConfigurationFile as a path hint, we trust it unconditionally and use its parent directory as the anchor — same as the original code did. The Exists guard caused tests that construct a mock configPath that doesn't physically exist in the MockFileSystem (used purely as an anchor) to fall through to ScanForDocset and throw DocumentationPathException. GitDir keeps its Exists check because an explicit git directory that does not exist is an error, not a hint. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../DocumentationPathsResolver.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs index 2a54d003b2..784be700f6 100644 --- a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs +++ b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs @@ -157,8 +157,8 @@ public static ResolvedDocumentationPaths Resolve( IFileSystem inner) { // 1-2. Anchor. Scoped to the invocation path only; skipped when the docset is already known. - var (source, configuration) = options.ConfigurationFile is { } known && inner.NewFileInfo(known) is { Exists: true } configFile - ? (configFile.Directory!, configFile) + var (source, configuration) = options.ConfigurationFile is { } known + ? (inner.NewFileInfo(known).Directory!, inner.NewFileInfo(known)) : ScanForDocset(invocation, inner); // 3. Checkout, derived from the anchor — never from the invocation. From 8fcf6252aeb1ecd5ad8d8dd2f0afc1328b643ab3 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 13 Aug 2026 11:20:48 +0200 Subject: [PATCH 29/29] Derive MaxParents from invocation-to-anchor distance, not a fixed default A docset discovered several levels below the invocation root by the recursive scan (step 2) could put .git out of reach of the default maxParents=1 walk in step 3, even though .git sits right at the invocation root. On a real filesystem this became a hard DocumentationPathException; on CI, IsolatedBuildService's stale-merge-commit catch swallowed it and skipped the build silently, turning what used to be a degraded `unknown-` render into a build that emits no HTML at all (elastic/infra's docs/resilience-team/docset.yml is exactly this shape). Widen the depth guard to at least the distance between the invocation root and the resolved anchor, so the no-`--path` case is a non-issue while a `--path` pointing deep into an unrelated tree is still bounded. Also promote the CI catch's log to a warning so the --git-dir remedy in the exception message isn't buried when the cause is a real bug rather than a stale merge commit. Co-Authored-By: Claude Sonnet 5 Co-authored-by: Cursor --- .../DocumentationPathsResolver.cs | 47 ++++++++++++++++--- .../IsolatedBuildService.cs | 6 ++- .../DocumentationPathsResolverTests.cs | 39 +++++++++++++++ 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs index 784be700f6..8b83146305 100644 --- a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs +++ b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs @@ -116,8 +116,11 @@ public sealed record DocumentationScopeOptions public IEnumerable? ExtraRoots { get; init; } /// - /// Maximum number of parent directories to walk above the docset anchor when searching for - /// .git (default: 1). + /// Minimum number of parent directories to walk above the docset anchor when searching for + /// .git (default: 1). This is a floor, not a cap: the resolver raises it to at least the + /// distance between the invocation root and the resolved anchor, so a docset discovered several + /// levels deep by the recursive scan (step 2) doesn't need this raised just to reach a .git + /// at the invocation root. /// public int MaxParents { get; init; } = 1; @@ -161,9 +164,18 @@ public static ResolvedDocumentationPaths Resolve( ? (inner.NewFileInfo(known).Directory!, inner.NewFileInfo(known)) : ScanForDocset(invocation, inner); + // 2b. Widen the depth guard to at least the distance between the invocation root and the + // anchor. The recursive docset scan (step 2) can legitimately land several levels down + // (e.g. a monorepo docset at `docs//docset.yml`); without this, `--git-dir`-less + // resolution would need `MaxParents` raised globally just to reach a `.git` that sits + // at the invocation root. Anchoring the allowance to the invocation keeps the guard + // meaningful for `--path` pointing deep into an unrelated tree, while making the + // no-`--path` case (invocation == repo root) a non-issue. + var maxParents = Math.Max(options.MaxParents, AnchorDepthBelowInvocation(invocation, source)); + // 3. Checkout, derived from the anchor — never from the invocation. - var gitScope = new GitResolveFileSystem(source, options.MaxParents, inner: inner); - var checkout = ResolveCheckout(gitScope, source, options, inner); + var gitScope = new GitResolveFileSystem(source, maxParents, inner: inner); + var checkout = ResolveCheckout(gitScope, source, options, maxParents, inner); // 4. Real git directories (the .git pointer path + resolved target for worktrees). // inner (unscoped) is used for worktree resolution: the resolved gitdir lives outside the @@ -180,7 +192,7 @@ public static ResolvedDocumentationPaths Resolve( // This step uses a GitResolveFileSystem (for .git-aware scoping) because it reads FILES // inside .git/ rather than listing directories at the scope root. var git = options.Git ?? GitCheckoutInformationFactory.Create(checkout, - new GitResolveFileSystem(source, options.MaxParents, gitDirectories, inner)); + new GitResolveFileSystem(source, maxParents, gitDirectories, inner)); // 6. Output. Default is relative to the checkout, not the invocation. // --path repo/docs and --path repo/ must both write to repo/.artifacts, not repo/docs/.artifacts. @@ -211,10 +223,31 @@ private static (IDirectoryInfo, IFileInfo) ScanForDocset(IDirectoryInfo invocati return (dir, file); } + /// + /// Counts how many directory levels sits below . + /// Returns 0 when the anchor is at or above the invocation root, or is not one of its descendants + /// (e.g. a pre-supplied ConfigurationFile living outside the invocation tree). + /// + private static int AnchorDepthBelowInvocation(IDirectoryInfo invocation, IDirectoryInfo anchor) + { + var invocationPath = invocation.FullName.TrimEnd('/', '\\'); + var depth = 0; + var directory = anchor; + while (directory is not null) + { + if (string.Equals(directory.FullName.TrimEnd('/', '\\'), invocationPath, StringComparison.OrdinalIgnoreCase)) + return depth; + directory = directory.Parent; + depth++; + } + return 0; + } + private static IDirectoryInfo ResolveCheckout( IFileSystem gitScope, IDirectoryInfo source, DocumentationScopeOptions options, + int maxParents, IFileSystem inner) { if (options.GitDir is { } configured && inner.NewDirInfo(configured) is { } explicitGitDir) @@ -228,7 +261,7 @@ private static IDirectoryInfo ResolveCheckout( ?? throw new DocumentationPathException($"--git-dir '{explicitGitDir.FullName}' has no parent directory."); } - var gitRoot = Paths.FindGitRoot(gitScope.DirectoryInfo.New(source.FullName), options.MaxParents); + var gitRoot = Paths.FindGitRoot(gitScope.DirectoryInfo.New(source.FullName), maxParents); if (gitRoot is not null) return gitRoot; @@ -239,7 +272,7 @@ private static IDirectoryInfo ResolveCheckout( return source; throw new DocumentationPathException( - $"No .git found at '{source.FullName}' or within {options.MaxParents} parent directory(ies). " + $"No .git found at '{source.FullName}' or within {maxParents} parent directory(ies). " + "Pass --git-dir to point at the repository's .git directory explicitly."); } diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index edc005d83a..67951bf718 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -112,7 +112,11 @@ public async Task Build( // we temporarily do not error when pointed to a non-documentation folder. _ = fallbackFs.Directory.CreateDirectory(outputDirectory.FullName); - _logger.LogInformation("Skipping build as we are running on a merge commit and the docs folder is out of date and has no docset.yml. {Message}", + // Surfaced as a warning (not swallowed at Information level) so that when the underlying + // cause is a real bug — not the stale-merge-commit case this catch was written for — + // the --git-dir remedy in e.Message actually reaches whoever is reading the failed run, + // rather than being buried above a later, unrelated artifact-upload failure. + _logger.LogWarning("Skipping build on CI: {Message} If the docs folder is not actually out of date on a stale merge commit, this indicates a real path-resolution issue.", e.Message); await githubActionsService.SetOutputAsync("skip", "true"); diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs index f11a0570ad..1f67f78ff1 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs @@ -143,6 +143,45 @@ public void PathRepoRoot_And_PathDocsSubfolder_ResolveIdenticalCheckoutAndSource "--path /repo and --path /repo/docs must converge on the same source"); } + [Fact] + public void AnchorTwoLevelsBelowInvocationRoot_StillResolvesCheckoutAtGitRoot() + { + // Mirrors the `elastic/infra` shape: no `--path` (invocation == repo root), and the only + // docset sits two levels down (`docs/resilience-team/docset.yml`). The recursive scan in + // step 2 finds it fine; without widening `MaxParents` by that same distance, step 3's + // default maxParents=1 can't see the `.git` two levels above the anchor and would either + // throw (real FS) or silently fall back to the wrong directory (mock FS leniency). + var fs = RegularRepo(docsRelative: "docs/resilience-team"); + var invocation = fs.DirectoryInfo.New("/repo"); + + var paths = DocumentationPathsResolver.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }, fs); + + paths.SourceDirectory.FullName.Should().Be(P(fs, "/repo/docs/resilience-team")); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo"), + "the anchor's depth below the invocation root should widen the git-root search, not require --git-dir"); + paths.Git.IsAvailable.Should().BeTrue(); + } + + [Fact] + public void AnchorAtInvocationRoot_UnrelatedAncestorGit_StillOutOfReach() + { + // The depth-widening must stay anchored to the invocation, not become unbounded: when the + // anchor IS the invocation (depth 0), an unrelated repo's .git two levels up must remain + // out of reach, exactly as before this change. + var fs = new MockFileSystem(); + fs.AddDirectory("/parent-repo/.git"); + fs.AddDirectory("/parent-repo/checkout"); + fs.AddFile("/parent-repo/checkout/docs/docset.yml", new MockFileData("toc: []\n")); + + var invocation = fs.DirectoryInfo.New("/parent-repo/checkout/docs"); + var opts = new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }; + + var paths = DocumentationPathsResolver.Resolve(invocation, opts, fs); + + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/parent-repo/checkout/docs"), + "depth-widening is relative to the invocation, so an ancestor repo's .git outside the invocation must not be adopted"); + } + [Fact] public void InvocationPath_StoredVerbatim_IndependentOfCheckout() {