Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3b7d3d1
Fix unknown-docs site name when --path points to docs subfolder
Mpdreamz Aug 6, 2026
a6a07f6
Bootstrap path resolution: FindGitRoot maxParents, fix .git scope, te…
Mpdreamz Aug 6, 2026
4618c33
Add ResolvedDocumentationPaths record with first-principles xmldoc
Mpdreamz Aug 6, 2026
ecaa1ca
Remove FindGitRoot(string) — all callers use IDirectoryInfo overload
Mpdreamz Aug 6, 2026
1754b60
Replace bare ScopedFileSystem with named use-case subclasses
Mpdreamz Aug 7, 2026
e809fb4
Bump Nullean.ScopedFileSystem to 0.4.2 (released); remove local nuget…
Mpdreamz Aug 7, 2026
d5e8162
Fix CheckoutsFileSystem disjointness when checkouts live inside AppData
Mpdreamz Aug 7, 2026
344959b
Migrate Codex/Assembler CLI commands from FileSystemFactory to Checko…
Mpdreamz Aug 7, 2026
e0d0651
Fix DocumentationFileSystem AppData disjointness on CI
Mpdreamz Aug 7, 2026
0e6a418
Fix DocumentationWriteFileSystem AppData disjointness on CI
Mpdreamz Aug 7, 2026
0f62895
Add CI checkout-inside-AppData regression tests
Mpdreamz Aug 7, 2026
57a4649
Fix test failures on Windows and CI
Mpdreamz Aug 7, 2026
211ee8c
Merge origin/main — resolve ApiExplorer test conflicts
Mpdreamz Aug 10, 2026
42f39b3
Fix import ordering in ApiExplorer test files
Mpdreamz Aug 10, 2026
9d397f9
Consolidate IsSubPath helpers; add Resolve(string) overload; validate…
Mpdreamz Aug 11, 2026
4ffc63f
Delete Scope* factory methods; migrate all test sites to named types
Mpdreamz Aug 11, 2026
6fb0b58
Replace ambient FileSystemFactory statics with named filesystem types
Mpdreamz Aug 11, 2026
f080d46
Collapse assembler/codex contexts to single CheckoutsFileSystem param
Mpdreamz Aug 11, 2026
487385d
Migrate changelog off FileSystemFactory; delete FileSystemFactory
Mpdreamz Aug 11, 2026
158a464
Add filesystem marker interfaces; create RunnerTempFileSystem
Mpdreamz Aug 11, 2026
f8c4ba1
Remove ReadFileSystem from IDocumentationContext base interface
Mpdreamz Aug 11, 2026
469ee92
Make DocumentationCheckoutDirectory non-nullable
Mpdreamz Aug 11, 2026
579047b
Remove plain new FileSystem() intermediary variables at call sites
Mpdreamz Aug 11, 2026
25953f8
Add CodexFileSystem; eliminate plain new FileSystem() in codex commands
Mpdreamz Aug 11, 2026
3b2b2b3
Tweak CodexFileSystem: output as string?, expression-body init
Mpdreamz Aug 11, 2026
128e085
CodexFileSystem takes string config instead of FileInfo
Mpdreamz Aug 11, 2026
e551283
Fix import ordering and expression-body style (dotnet format)
Mpdreamz Aug 11, 2026
a32f70a
Merge remote-tracking branch 'origin/main' into fix/path-unknown-docs…
Mpdreamz Aug 11, 2026
0a4b2ac
Merge main; fix new BundleFilesFilterTests call site argument order
Mpdreamz Aug 11, 2026
23d76e5
Drop Exists check on ConfigurationFile in resolver
Mpdreamz Aug 11, 2026
1a53bea
Merge origin/main — retire client-side registry refresh
Mpdreamz Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Telemetry.Abstractions" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.6.0" />
<PackageVersion Include="Nullean.ScopedFileSystem" Version="0.4.0" />
<PackageVersion Include="Nullean.ScopedFileSystem" Version="0.4.2" />
<PackageVersion Include="Pagefind.Net" Version="0.4.1" />
<PackageVersion Include="Pagefind.Net.Frontend" Version="0.4.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
Expand Down
25 changes: 10 additions & 15 deletions src/Elastic.Codex/Building/CodexBuildService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,7 +70,7 @@ public async Task<CodexBuildResult> BuildAll(
var buildContexts = new List<CodexDocumentationSetBuildContext>();

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)
Expand Down Expand Up @@ -187,16 +188,13 @@ public async Task<CodexBuildResult> 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 = outputPath,
Git = git,
ConfigurationFile = checkout.DocsetFile.FullName,
});
var buildContext = new BuildContext(context.Collector, docFs, configurationContext)
{
UrlPathPrefix = pathPrefix,
SiteRootPath = siteRootPath,
Expand Down Expand Up @@ -414,10 +412,7 @@ internal sealed class CodexDocumentationContext(CodexContext codexContext) : ICo
public IDiagnosticsCollector Collector => codexContext.Collector;

/// <inheritdoc />
public ScopedFileSystem ReadFileSystem => codexContext.ReadFileSystem;

/// <inheritdoc />
public ScopedFileSystem WriteFileSystem => codexContext.WriteFileSystem;
public DocumentationWriteFileSystem WriteFileSystem => codexContext.WriteFileSystem;

/// <inheritdoc />
public IDirectoryInfo OutputDirectory => codexContext.OutputDirectory;
Expand Down
22 changes: 11 additions & 11 deletions src/Elastic.Codex/CodexContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
using Elastic.Documentation.Configuration.Codex;
using Elastic.Documentation.Deploying.Synchronization;
using Elastic.Documentation.Diagnostics;
using Nullean.ScopedFileSystem;
using Elastic.Documentation.FileSystems;

namespace Elastic.Codex;

Expand All @@ -16,8 +16,8 @@ namespace Elastic.Codex;
/// </summary>
public class CodexContext : IDocsSyncContext
{
public ScopedFileSystem ReadFileSystem { get; }
public ScopedFileSystem WriteFileSystem { get; }
public CheckoutsFileSystem ReadFileSystem { get; }
public DocumentationWriteFileSystem WriteFileSystem { get; }
public IDiagnosticsCollector Collector { get; }
public CodexConfiguration Configuration { get; }
public IFileInfo ConfigurationPath { get; }
Expand All @@ -37,23 +37,23 @@ public CodexContext(
CodexConfiguration configuration,
IFileInfo configurationPath,
IDiagnosticsCollector collector,
ScopedFileSystem readFileSystem,
ScopedFileSystem 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
? FileSystemFactory.AppData.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);
Expand Down
41 changes: 41 additions & 0 deletions src/Elastic.Codex/CodexFileSystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 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;

/// <summary>
/// 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.
/// <para>
/// Use this in codex commands instead of constructing a <see cref="CheckoutsFileSystem"/> manually.
/// </para>
/// </summary>
public class CodexFileSystem : CheckoutsFileSystem
{
private static readonly FileSystem Physical = new();

/// <summary>The codex configuration file, resolved through this scoped filesystem.</summary>
public IFileInfo ConfigurationFile { get; }

/// <param name="config">Full path to the codex configuration file. Its directory is used to locate the git root.</param>
/// <param name="output">Optional explicit output directory.</param>
/// <param name="inner">Underlying filesystem — defaults to the physical filesystem when <see langword="null"/>.</param>
public CodexFileSystem(string config, string? output = null, IFileSystem? inner = null)
: this(inner ?? Physical, config, output, 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(fs.Path.GetDirectoryName(config)!))?.FullName ?? fs.Path.GetDirectoryName(config)!]
)
=> ConfigurationFile = FileInfo.New(config);
}
104 changes: 30 additions & 74 deletions src/Elastic.Documentation.Configuration/BuildContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,46 +23,42 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati
public static string Version { get; } = Assembly.GetExecutingAssembly().GetCustomAttributes<AssemblyInformationalVersionAttribute>()
.FirstOrDefault()?.InformationalVersion ?? "0.0.0";

public ScopedFileSystem ReadFileSystem { get; }
public ScopedFileSystem WriteFileSystem { get; }
public IReadOnlySet<Exporter> AvailableExporters { get; }
/// <summary>The resolved documentation filesystem. All other path/scope properties are computed from this.</summary>
public DocumentationFileSystem FileSystem { get; }

public IDirectoryInfo? DocumentationCheckoutDirectory { get; }
public IDirectoryInfo DocumentationSourceDirectory { get; }
public IDirectoryInfo OutputDirectory { get; }
/// <summary>
/// Read scope. Satisfies <see cref="IDocumentationSetContext"/>.
/// Use <see cref="FileSystem"/> directly when the richer type is needed.
/// </summary>
public IDocumentationFileSystem ReadFileSystem => FileSystem.Read;

public ConfigurationFile Configuration { get; private set; }
/// <summary>Write scope. Does not permit <c>.git</c> writes.</summary>
public DocumentationWriteFileSystem WriteFileSystem => FileSystem.Write;

public IReadOnlySet<Exporter> 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
Expand All @@ -73,33 +70,20 @@ public string? UrlPathPrefix
/// <summary>Site root path for HTMX (e.g. codex root). When set, overrides derivation from UrlPathPrefix.</summary>
public string? SiteRootPath { get; init; }

/// <summary>
/// Primary constructor. Pass a resolved <see cref="DocumentationFileSystem"/> from
/// <see cref="DocumentationFileSystem.Resolve(IDirectoryInfo?, DocumentationScopeOptions?)"/>.
/// </summary>
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<Exporter> 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;
Expand All @@ -108,50 +92,22 @@ 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);

DocumentationCheckoutDirectory = Paths.FindGitRoot(DocumentationSourceDirectory, ceiling: rootFolder);

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
};
}

/// <summary>Re-reads docset.yml from disk and rebuilds the configuration. Used by the serve command on file changes.</summary>
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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;
Expand All @@ -17,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<ConfigurationFileProvider> _logger;

Expand All @@ -37,7 +38,7 @@ public partial class ConfigurationFileProvider

public ConfigurationFileProvider(
ILoggerFactory logFactory,
IFileSystem fileSystem,
IAppDataFileSystem fileSystem,
bool skipPrivateRepositories = false,
ConfigurationSource? configurationSource = null
)
Expand Down Expand Up @@ -272,7 +273,7 @@ public static IServiceCollection AddConfigurationFileProvider(this IServiceColle
{
using var sp = services.BuildServiceProvider();
var logFactory = sp.GetRequiredService<ILoggerFactory>();
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -126,7 +127,7 @@ public static DocumentationSetFile LoadMetadata(IFileInfo file)
/// </summary>
public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, IFileInfo docsetPath, ScopedFileSystem? fileSystem = null, HashSet<HintType>? 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);
Expand All @@ -148,7 +149,7 @@ public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collecto
/// </summary>
public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, string yaml, IDirectoryInfo sourceDirectory, ScopedFileSystem? fileSystem = null, HashSet<HintType>? 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 ?? []);
Expand Down
Loading
Loading