From fba53560db632f334614c08ad77522dbbd30fc5b Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Aug 2026 22:03:01 +0200 Subject: [PATCH 1/7] Add git-diff exporter for CI changed-pages.json Preview comments need builder URLs, not guessed paths from GitHub file lists. --- docs/data/exporters/git-diff.md | 62 +++++++ docs/data/exporters/index.md | 4 + src/Elastic.Documentation.Tooling/Exporter.cs | 4 +- .../GitDiff/ChangedPagesExport.cs | 56 +++++++ .../Serialization/SourceGenerationContext.cs | 2 + .../Exporters/ExporterExtensions.cs | 3 + .../Exporters/GitDiff/BuiltPageInfo.cs | 7 + .../Exporters/GitDiff/ChangedPagesMapper.cs | 155 ++++++++++++++++++ .../Exporters/GitDiff/GitChangedFileSource.cs | 155 ++++++++++++++++++ .../GitDiff/GitDiffMarkdownExporter.cs | 110 +++++++++++++ .../GitDiff/GitDiffPathNormalization.cs | 42 +++++ .../GitDiff/IntegrationChangedFileSource.cs | 62 +++++++ .../Exporters/GitDiff/SourceFileChange.cs | 24 +++ .../Building/ExporterParser.cs | 5 +- .../ExporterParser.cs | 6 +- .../IsolatedBuildService.cs | 5 +- .../Exporters/ChangedPagesMapperTests.cs | 102 ++++++++++++ .../Exporters/GitChangedFileSourceTests.cs | 56 +++++++ .../Exporters/GitDiffMarkdownExporterTests.cs | 60 +++++++ .../GitDiffPathNormalizationTests.cs | 38 +++++ 20 files changed, 954 insertions(+), 4 deletions(-) create mode 100644 docs/data/exporters/git-diff.md create mode 100644 src/Elastic.Documentation/GitDiff/ChangedPagesExport.cs create mode 100644 src/Elastic.Markdown/Exporters/GitDiff/BuiltPageInfo.cs create mode 100644 src/Elastic.Markdown/Exporters/GitDiff/ChangedPagesMapper.cs create mode 100644 src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs create mode 100644 src/Elastic.Markdown/Exporters/GitDiff/GitDiffMarkdownExporter.cs create mode 100644 src/Elastic.Markdown/Exporters/GitDiff/GitDiffPathNormalization.cs create mode 100644 src/Elastic.Markdown/Exporters/GitDiff/IntegrationChangedFileSource.cs create mode 100644 src/Elastic.Markdown/Exporters/GitDiff/SourceFileChange.cs create mode 100644 tests/Elastic.Markdown.Tests/Exporters/ChangedPagesMapperTests.cs create mode 100644 tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs create mode 100644 tests/Elastic.Markdown.Tests/Exporters/GitDiffMarkdownExporterTests.cs create mode 100644 tests/Elastic.Markdown.Tests/Exporters/GitDiffPathNormalizationTests.cs diff --git a/docs/data/exporters/git-diff.md b/docs/data/exporters/git-diff.md new file mode 100644 index 0000000000..31556dc9b2 --- /dev/null +++ b/docs/data/exporters/git-diff.md @@ -0,0 +1,62 @@ +--- +navigation_title: Git diff +--- + +# Git diff exporter + +The Git diff exporter maps the current branch diff onto published documentation pages and writes `changed-pages.json` to the build output. CI workflows use this file to post preview links that match the URLs the builder generates. + +## How it works + +During the HTML build, the exporter collects every published page (source path, navigation URL, title) and the include graph from `{include}` and `{csv-include}` directives. At the end of the build it reads the git diff against a base ref and writes a JSON artifact. + +Changed snippet or data files map to the pages that include them. Changed configuration files set `config_changed: true` so workflows can link to the full preview instead of listing every page. + +## Output + +The exporter writes `changed-pages.json` next to `links.json`: + +```json +{ + "base": "origin/main", + "config_changed": false, + "pages": [ + { + "source_path": "guides/start.md", + "url": "/_preview/org/repo/pull/1/guides/start", + "title": "Get started", + "change": "modified", + "included_from": [] + } + ], + "deleted": [{ "source_path": "guides/old.md" }] +} +``` + +URLs are path-only. Workflows prepend the preview host (for example `https://codex.elastic.dev`). + +## Enabling + +The exporter is **not** part of the default exporter set. Enable it explicitly: + +```bash +docs-builder --exporters default,gitdiff +``` + +On CI (`GITHUB_ACTIONS` set), isolated builds enable it automatically. + +## Diff base resolution + +If `ADDED_FILES`, `MODIFIED_FILES`, `DELETED_FILES`, or `RENAMED_FILES` are set (GitHub Actions changed-file lists), the exporter uses those and does not run git. + +Otherwise it resolves the git diff base in this order: + +1. `DOCS_DIFF_BASE` environment variable +2. `GITHUB_BASE_REF` → `origin/` +3. `main`, then `master`, then `origin/HEAD` + +Then it runs `git diff --name-status -z HEAD`. + +## Failure behavior + +Git errors do not fail the build. The exporter logs a warning and writes an empty `pages` array. diff --git a/docs/data/exporters/index.md b/docs/data/exporters/index.md index eb6378ae38..2ed99ebc39 100644 --- a/docs/data/exporters/index.md +++ b/docs/data/exporters/index.md @@ -25,3 +25,7 @@ An [Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-cata ### [Plain Text](./plain-text.md) Stripped-down plain text with all formatting removed. Used internally by other exporters (notably Elasticsearch) for search indexing. + +### [Git diff](./git-diff.md) + +Maps the git diff to published page URLs and titles. Writes `changed-pages.json` for CI preview comment jobs. diff --git a/src/Elastic.Documentation.Tooling/Exporter.cs b/src/Elastic.Documentation.Tooling/Exporter.cs index 54b151e0cf..a93572f4ea 100644 --- a/src/Elastic.Documentation.Tooling/Exporter.cs +++ b/src/Elastic.Documentation.Tooling/Exporter.cs @@ -17,7 +17,9 @@ public enum Exporter LinkMetadata, Redirects, Okf, - Pagefind + Pagefind, + [EnumValue("gitdiff")] + GitDiff } public static class ExportOptions diff --git a/src/Elastic.Documentation/GitDiff/ChangedPagesExport.cs b/src/Elastic.Documentation/GitDiff/ChangedPagesExport.cs new file mode 100644 index 0000000000..82ebe9036c --- /dev/null +++ b/src/Elastic.Documentation/GitDiff/ChangedPagesExport.cs @@ -0,0 +1,56 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using Elastic.Documentation.Serialization; + +namespace Elastic.Documentation.GitDiff; + +public static class ChangedPagesExportFile +{ + public const string FileName = "changed-pages.json"; + + public static string Serialize(ChangedPagesExport export) => + JsonSerializer.Serialize(export, SourceGenerationContext.Default.ChangedPagesExport); +} + +public record ChangedPagesExport +{ + [JsonPropertyName("base")] + public required string Base { get; init; } + + [JsonPropertyName("config_changed")] + public bool ConfigChanged { get; init; } + + [JsonPropertyName("pages")] + public required IReadOnlyList Pages { get; init; } + + [JsonPropertyName("deleted")] + public required IReadOnlyList Deleted { get; init; } +} + +public record ChangedPageEntry +{ + [JsonPropertyName("source_path")] + public required string SourcePath { get; init; } + + [JsonPropertyName("url")] + public required string Url { get; init; } + + [JsonPropertyName("title")] + public required string Title { get; init; } + + [JsonPropertyName("change")] + public required string Change { get; init; } + + [JsonPropertyName("included_from")] + public required IReadOnlyList IncludedFrom { get; init; } +} + +public record DeletedPageEntry +{ + [JsonPropertyName("source_path")] + public required string SourcePath { get; init; } +} diff --git a/src/Elastic.Documentation/Serialization/SourceGenerationContext.cs b/src/Elastic.Documentation/Serialization/SourceGenerationContext.cs index 6dc6cfa38e..9fc930e3c5 100644 --- a/src/Elastic.Documentation/Serialization/SourceGenerationContext.cs +++ b/src/Elastic.Documentation/Serialization/SourceGenerationContext.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using Elastic.Documentation.AppliesTo; +using Elastic.Documentation.GitDiff; using Elastic.Documentation.Links; using Elastic.Documentation.State; using Elastic.Documentation.Versions; @@ -29,4 +30,5 @@ namespace Elastic.Documentation.Serialization; [JsonSerializable(typeof(SemVersion))] [JsonSerializable(typeof(VersionSpec))] [JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(ChangedPagesExport))] public sealed partial class SourceGenerationContext : JsonSerializerContext; diff --git a/src/Elastic.Markdown/Exporters/ExporterExtensions.cs b/src/Elastic.Markdown/Exporters/ExporterExtensions.cs index 16ac8b45fa..e1c52de39d 100644 --- a/src/Elastic.Markdown/Exporters/ExporterExtensions.cs +++ b/src/Elastic.Markdown/Exporters/ExporterExtensions.cs @@ -5,6 +5,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Markdown.Exporters.Elasticsearch; +using Elastic.Markdown.Exporters.GitDiff; using Elastic.Markdown.Exporters.Pagefind; using Microsoft.Extensions.Logging; @@ -30,6 +31,8 @@ public static IReadOnlyCollection CreateMarkdownExporters( markdownExporters.Add(new OkfMarkdownExporter()); if (exportOptions.Contains(Exporter.Pagefind)) markdownExporters.Add(new PagefindMarkdownExporter(logFactory)); + if (exportOptions.Contains(Exporter.GitDiff)) + markdownExporters.Add(new GitDiffMarkdownExporter(logFactory)); return markdownExporters; } } diff --git a/src/Elastic.Markdown/Exporters/GitDiff/BuiltPageInfo.cs b/src/Elastic.Markdown/Exporters/GitDiff/BuiltPageInfo.cs new file mode 100644 index 0000000000..650a22bcf8 --- /dev/null +++ b/src/Elastic.Markdown/Exporters/GitDiff/BuiltPageInfo.cs @@ -0,0 +1,7 @@ +// 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.Markdown.Exporters.GitDiff; + +internal record BuiltPageInfo(string Url, string Title); diff --git a/src/Elastic.Markdown/Exporters/GitDiff/ChangedPagesMapper.cs b/src/Elastic.Markdown/Exporters/GitDiff/ChangedPagesMapper.cs new file mode 100644 index 0000000000..688353f996 --- /dev/null +++ b/src/Elastic.Markdown/Exporters/GitDiff/ChangedPagesMapper.cs @@ -0,0 +1,155 @@ +// 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 Elastic.Documentation.GitDiff; + +namespace Elastic.Markdown.Exporters.GitDiff; + +internal static class ChangedPagesMapper +{ + private static readonly HashSet ConfigFileNames = new(StringComparer.OrdinalIgnoreCase) + [ + "docset.yml", + "_docset.yml", + "redirects.yml", + "toc.yml", + "navigation.yml", + "navigation_preview.yml", + "products.yml", + "versions.yml", + "legacy-url-mappings.yml", + "assembler.yml", + "search.yml", + ]; + + public static ChangedPagesExport Map( + string diffBase, + string docsetPrefix, + IReadOnlyDictionary builtPages, + IReadOnlyDictionary> includeIndex, + IReadOnlyList changes + ) + { + var configChanged = false; + var deleted = new List(); + var pageEntries = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var change in changes) + { + if (!GitDiffPathNormalization.TryToDocsetRelative(change.Path, docsetPrefix, out var docsetPath)) + continue; + + if (IsConfigFile(docsetPath, change.Path)) + { + configChanged = true; + continue; + } + + switch (change.ChangeType) + { + case SourceFileChangeType.Deleted: + if (GitDiffPathNormalization.IsMarkdownPagePath(docsetPath)) + deleted.Add(new DeletedPageEntry { SourcePath = docsetPath }); + break; + + case SourceFileChangeType.Renamed: + if (GitDiffPathNormalization.IsMarkdownPagePath(docsetPath)) + deleted.Add(new DeletedPageEntry { SourcePath = docsetPath }); + if (GitDiffPathNormalization.TryToDocsetRelative(change.NewPath ?? string.Empty, docsetPrefix, out var newDocsetPath)) + { + TryAddDirectPage(pageEntries, builtPages, newDocsetPath, "renamed"); + TryAddAffectedByInclude(pageEntries, builtPages, includeIndex, newDocsetPath); + } + break; + + default: + var changeLabel = change.ChangeType == SourceFileChangeType.Added ? "added" : "modified"; + TryAddDirectPage(pageEntries, builtPages, docsetPath, changeLabel); + TryAddAffectedByInclude(pageEntries, builtPages, includeIndex, docsetPath); + break; + } + } + + var pages = pageEntries.Values + .OrderBy(p => p.SourcePath, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + deleted.Sort(static (a, b) => string.Compare(a.SourcePath, b.SourcePath, StringComparison.OrdinalIgnoreCase)); + + return new ChangedPagesExport + { + Base = diffBase, + ConfigChanged = configChanged, + Pages = pages, + Deleted = deleted + }; + } + + private static bool IsConfigFile(string docsetPath, string repoPath) + { + var fileName = Path.GetFileName(string.IsNullOrEmpty(docsetPath) ? repoPath : docsetPath); + return ConfigFileNames.Contains(fileName); + } + + private static void TryAddDirectPage( + Dictionary pageEntries, + IReadOnlyDictionary builtPages, + string docsetPath, + string change + ) + { + if (!GitDiffPathNormalization.IsMarkdownPagePath(docsetPath)) + return; + + if (!builtPages.TryGetValue(docsetPath, out var page)) + return; + + pageEntries[docsetPath] = new ChangedPageEntry + { + SourcePath = docsetPath, + Url = page.Url, + Title = page.Title, + Change = change, + IncludedFrom = [] + }; + } + + private static void TryAddAffectedByInclude( + Dictionary pageEntries, + IReadOnlyDictionary builtPages, + IReadOnlyDictionary> includeIndex, + string changedDocsetPath + ) + { + if (!includeIndex.TryGetValue(changedDocsetPath, out var affectedPages)) + return; + + foreach (var pagePath in affectedPages) + { + if (!builtPages.TryGetValue(pagePath, out var page)) + continue; + + if (pageEntries.TryGetValue(pagePath, out var existing)) + { + if (existing.IncludedFrom.Contains(changedDocsetPath)) + continue; + + pageEntries[pagePath] = existing with + { + IncludedFrom = [.. existing.IncludedFrom, changedDocsetPath] + }; + continue; + } + + pageEntries[pagePath] = new ChangedPageEntry + { + SourcePath = pagePath, + Url = page.Url, + Title = page.Title, + Change = "modified", + IncludedFrom = [changedDocsetPath] + }; + } + } +} diff --git a/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs b/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs new file mode 100644 index 0000000000..6066ff8f62 --- /dev/null +++ b/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs @@ -0,0 +1,155 @@ +// 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.Diagnostics; +using System.IO.Abstractions; +using Elastic.Documentation; +using Microsoft.Extensions.Logging; + +namespace Elastic.Markdown.Exporters.GitDiff; + +internal sealed class GitChangedFileSource( + ILoggerFactory logFactory, + IDirectoryInfo checkoutDirectory, + string docsetPrefix, + IEnvironmentVariables environment +) +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + + public ChangedFileSourceResult GetChanges() + { + if (IntegrationChangedFileSource.HasFileList(environment)) + { + var listedBase = environment.GetEnvironmentVariable("DOCS_DIFF_BASE") + ?? environment.GetEnvironmentVariable("GITHUB_BASE_REF") + ?? "ci"; + return IntegrationChangedFileSource.GetChanges(docsetPrefix, environment, listedBase); + } + + var diffBase = ResolveDiffBase(); + return new ChangedFileSourceResult(diffBase, RunGitDiff(diffBase)); + } + + private string ResolveDiffBase() + { + var explicitBase = environment.GetEnvironmentVariable("DOCS_DIFF_BASE"); + if (!string.IsNullOrWhiteSpace(explicitBase)) + return explicitBase.Trim(); + + var githubBaseRef = environment.GetEnvironmentVariable("GITHUB_BASE_REF"); + if (!string.IsNullOrWhiteSpace(githubBaseRef)) + return $"origin/{githubBaseRef.Trim()}"; + + foreach (var candidate in new[] { "main", "master" }) + { + var output = GitCommand("merge-base", "-a", "HEAD", candidate); + if (output.Length > 0 && !output.StartsWith("fatal", StringComparison.Ordinal)) + return candidate; + } + + var originHead = GitCommand("symbolic-ref", "refs/remotes/origin/HEAD"); + if (originHead.Length > 0 && !originHead.StartsWith("fatal", StringComparison.Ordinal)) + { + var parts = originHead.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length >= 2) + return $"{parts[^2]}/{parts[^1]}"; + } + + return "main"; + } + + private IReadOnlyList RunGitDiff(string diffBase) + { + var lookupPath = GitDiffPathNormalization.Normalize(docsetPrefix); + var args = new List { "diff", "--name-status", "-z", diffBase, "HEAD" }; + if (!string.IsNullOrEmpty(lookupPath)) + args.AddRange(["--", $"./{lookupPath}"]); + + var output = GitCommand([.. args]); + return output.Length == 0 ? [] : ParseNameStatus(output); + } + + internal static IReadOnlyList ParseNameStatus(string output) + { + var changes = new List(); + var parts = output.Split('\0', StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < parts.Length;) + { + var status = parts[i++]; + if (status.Length == 0) + continue; + + if (status[0] is 'R' or 'C') + { + if (i + 1 >= parts.Length) + break; + + var oldPath = parts[i++]; + var newPath = parts[i++]; + changes.Add(new SourceFileChange(oldPath, SourceFileChangeType.Renamed, newPath)); + continue; + } + + if (i >= parts.Length) + break; + + var path = parts[i++]; + var changeType = status[0] switch + { + 'A' => SourceFileChangeType.Added, + 'M' => SourceFileChangeType.Modified, + 'D' => SourceFileChangeType.Deleted, + _ => SourceFileChangeType.Modified + }; + changes.Add(new SourceFileChange(path, changeType)); + } + + return changes; + } + + private string GitCommand(params string[] args) + { + try + { + var startInfo = new ProcessStartInfo("git") + { + WorkingDirectory = checkoutDirectory.FullName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + foreach (var arg in args) + startInfo.ArgumentList.Add(arg); + + using var process = Process.Start(startInfo); + if (process is null) + return string.Empty; + + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(30_000)) + { + process.Kill(entireProcessTree: true); + _logger.LogWarning("git {Args} timed out after 30s", string.Join(' ', args)); + return string.Empty; + } + + _ = stderr.GetAwaiter().GetResult(); + if (process.ExitCode != 0) + { + _logger.LogWarning("git {Args} failed with exit code {ExitCode}", string.Join(' ', args), process.ExitCode); + return string.Empty; + } + + return stdout.GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to run git {Args}", string.Join(' ', args)); + return string.Empty; + } + } +} diff --git a/src/Elastic.Markdown/Exporters/GitDiff/GitDiffMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/GitDiff/GitDiffMarkdownExporter.cs new file mode 100644 index 0000000000..3d90153a13 --- /dev/null +++ b/src/Elastic.Markdown/Exporters/GitDiff/GitDiffMarkdownExporter.cs @@ -0,0 +1,110 @@ +// 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.Collections.Concurrent; +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.GitDiff; +using Elastic.Markdown.Myst.Directives.CsvInclude; +using Elastic.Markdown.Myst.Directives.Include; +using Markdig.Syntax; +using Microsoft.Extensions.Logging; + +namespace Elastic.Markdown.Exporters.GitDiff; + +public sealed class GitDiffMarkdownExporter(ILoggerFactory logFactory) : IMarkdownExporter +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly ConcurrentDictionary _builtPages = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> _includeIndex = new(StringComparer.OrdinalIgnoreCase); + private BuildContext? _buildContext; + private string _docsetPrefix = string.Empty; + + public ValueTask StartAsync(Cancel ctx = default) => ValueTask.CompletedTask; + + public ValueTask StopAsync(Cancel ctx = default) => ValueTask.CompletedTask; + + public ValueTask ExportAsync(MarkdownExportFileContext fileContext, Cancel ctx) + { + if (_buildContext is null) + { + _buildContext = fileContext.BuildContext; + _docsetPrefix = GitDiffPathNormalization.Normalize(Path.GetRelativePath( + fileContext.BuildContext.DocumentationCheckoutDirectory.FullName, + fileContext.BuildContext.DocumentationSourceDirectory.FullName + )); + } + + var sourcePath = GitDiffPathNormalization.Normalize(fileContext.SourceFile.RelativePath); + _builtPages[sourcePath] = new BuiltPageInfo( + fileContext.NavigationItem.Url, + fileContext.SourceFile.Title + ); + + foreach (var includePath in CollectIncludePaths(fileContext.Document)) + { + var normalizedInclude = GitDiffPathNormalization.Normalize(includePath); + _ = _includeIndex.GetOrAdd(normalizedInclude, static _ => new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase)) + .TryAdd(sourcePath, 0); + } + + return ValueTask.FromResult(true); + } + + public async ValueTask FinishExportAsync(IDirectoryInfo outputFolder, Cancel ctx) + { + if (_buildContext is null) + { + _logger.LogWarning("Git diff exporter did not process any pages; skipping changed-pages.json"); + return true; + } + + var changeResult = new GitChangedFileSource( + logFactory, + _buildContext.DocumentationCheckoutDirectory, + _docsetPrefix, + _buildContext.Environment + ).GetChanges(); + + var includeIndex = _includeIndex.ToDictionary( + static pair => pair.Key, + static pair => (IReadOnlyCollection)pair.Value.Keys, + StringComparer.OrdinalIgnoreCase); + + var export = ChangedPagesMapper.Map( + changeResult.Base, + _docsetPrefix, + _builtPages, + includeIndex, + changeResult.Changes + ); + + if (!outputFolder.Exists) + outputFolder.Create(); + + var outputPath = Path.Join(outputFolder.FullName, ChangedPagesExportFile.FileName); + await _buildContext.WriteFileSystem.File.WriteAllTextAsync(outputPath, ChangedPagesExportFile.Serialize(export), ctx); + _logger.LogInformation("Wrote {Count} changed pages to {OutputPath}", export.Pages.Count, outputPath); + return true; + } + + private static IEnumerable CollectIncludePaths(MarkdownDocument document) + { + foreach (var includeBlock in document.Descendants()) + { + if (!includeBlock.Found || string.IsNullOrWhiteSpace(includeBlock.IncludePathRelativeToSource)) + continue; + + yield return includeBlock.IncludePathRelativeToSource; + } + + foreach (var csvIncludeBlock in document.Descendants()) + { + if (!csvIncludeBlock.Found || string.IsNullOrWhiteSpace(csvIncludeBlock.CsvFilePathRelativeToSource)) + continue; + + yield return csvIncludeBlock.CsvFilePathRelativeToSource; + } + } +} diff --git a/src/Elastic.Markdown/Exporters/GitDiff/GitDiffPathNormalization.cs b/src/Elastic.Markdown/Exporters/GitDiff/GitDiffPathNormalization.cs new file mode 100644 index 0000000000..317269b758 --- /dev/null +++ b/src/Elastic.Markdown/Exporters/GitDiff/GitDiffPathNormalization.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 + +namespace Elastic.Markdown.Exporters.GitDiff; + +internal static class GitDiffPathNormalization +{ + public static string Normalize(string path) => + path.Replace('\\', '/').TrimStart('.').TrimStart('/'); + + public static bool TryToDocsetRelative(string repoRelativePath, string docsetPrefix, out string relative) + { + var normalized = Normalize(repoRelativePath); + var prefix = Normalize(docsetPrefix); + if (string.IsNullOrEmpty(prefix)) + { + relative = normalized; + return true; + } + + if (normalized.StartsWith($"{prefix}/", StringComparison.OrdinalIgnoreCase)) + { + relative = normalized[(prefix.Length + 1)..]; + return true; + } + + if (string.Equals(normalized, prefix, StringComparison.OrdinalIgnoreCase)) + { + relative = string.Empty; + return true; + } + + relative = string.Empty; + return false; + } + + public static bool IsMarkdownPagePath(string path) => + path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + && !path.Contains("/_snippets/", StringComparison.OrdinalIgnoreCase) + && !path.StartsWith("_snippets/", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Elastic.Markdown/Exporters/GitDiff/IntegrationChangedFileSource.cs b/src/Elastic.Markdown/Exporters/GitDiff/IntegrationChangedFileSource.cs new file mode 100644 index 0000000000..45fe6cbb1c --- /dev/null +++ b/src/Elastic.Markdown/Exporters/GitDiff/IntegrationChangedFileSource.cs @@ -0,0 +1,62 @@ +// 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 Elastic.Documentation; + +namespace Elastic.Markdown.Exporters.GitDiff; + +internal static class IntegrationChangedFileSource +{ + public static bool HasFileList(IEnvironmentVariables environment) => + HasValue(environment, "ADDED_FILES") + || HasValue(environment, "MODIFIED_FILES") + || HasValue(environment, "DELETED_FILES") + || HasValue(environment, "RENAMED_FILES"); + + public static ChangedFileSourceResult GetChanges(string docsetPrefix, IEnvironmentVariables environment, string diffBase) + { + var changes = new List(); + + AddChanges(environment.GetEnvironmentVariable("DELETED_FILES"), SourceFileChangeType.Deleted, docsetPrefix, changes); + AddChanges(environment.GetEnvironmentVariable("ADDED_FILES"), SourceFileChangeType.Added, docsetPrefix, changes); + AddChanges(environment.GetEnvironmentVariable("MODIFIED_FILES"), SourceFileChangeType.Modified, docsetPrefix, changes); + AddRenames(environment.GetEnvironmentVariable("RENAMED_FILES"), docsetPrefix, changes); + + return new ChangedFileSourceResult(diffBase, changes); + } + + private static bool HasValue(IEnvironmentVariables environment, string name) => + !string.IsNullOrWhiteSpace(environment.GetEnvironmentVariable(name)); + + private static void AddChanges(string? raw, SourceFileChangeType changeType, string docsetPrefix, List changes) + { + if (string.IsNullOrWhiteSpace(raw)) + return; + + foreach (var file in raw.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (GitDiffPathNormalization.TryToDocsetRelative(file, docsetPrefix, out _)) + changes.Add(new SourceFileChange(file, changeType)); + } + } + + private static void AddRenames(string? raw, string docsetPrefix, List changes) + { + if (string.IsNullOrWhiteSpace(raw)) + return; + + foreach (var pair in raw.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var parts = pair.Split(':', 2); + if (parts.Length != 2) + continue; + + if (!GitDiffPathNormalization.TryToDocsetRelative(parts[0], docsetPrefix, out _) + && !GitDiffPathNormalization.TryToDocsetRelative(parts[1], docsetPrefix, out _)) + continue; + + changes.Add(new SourceFileChange(parts[0], SourceFileChangeType.Renamed, parts[1])); + } + } +} diff --git a/src/Elastic.Markdown/Exporters/GitDiff/SourceFileChange.cs b/src/Elastic.Markdown/Exporters/GitDiff/SourceFileChange.cs new file mode 100644 index 0000000000..f8717004a3 --- /dev/null +++ b/src/Elastic.Markdown/Exporters/GitDiff/SourceFileChange.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 + +namespace Elastic.Markdown.Exporters.GitDiff; + +internal enum SourceFileChangeType +{ + Added, + Modified, + Deleted, + Renamed +} + +internal record SourceFileChange( + string Path, + SourceFileChangeType ChangeType, + string? NewPath = null +); + +internal record ChangedFileSourceResult( + string Base, + IReadOnlyList Changes +); diff --git a/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs b/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs index a2155af2f6..c156f991ef 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs @@ -59,6 +59,9 @@ public bool TryParse(string raw, out IReadOnlySet result) case "pagefind": _ = set.Add(Exporter.Pagefind); break; + case "gitdiff": + _ = set.Add(Exporter.GitDiff); + break; case "none": break; case "default": @@ -71,7 +74,7 @@ public bool TryParse(string raw, out IReadOnlySet result) break; default: throw new ArgumentException( - $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, pagefind, default, metadata, none."); + $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, pagefind, gitdiff, default, metadata, none."); } } result = set; diff --git a/src/services/Elastic.Documentation.Isolated/ExporterParser.cs b/src/services/Elastic.Documentation.Isolated/ExporterParser.cs index 72c26b6710..c62c420a6d 100644 --- a/src/services/Elastic.Documentation.Isolated/ExporterParser.cs +++ b/src/services/Elastic.Documentation.Isolated/ExporterParser.cs @@ -15,6 +15,7 @@ namespace Elastic.Documentation.Isolated; /// links/linkmetadata, state/documentationstate, redirect/redirects, okf, /// default (expands to ), /// metadata (expands to ), +/// gitdiff (changed-pages.json for CI preview comments), /// none (empty set). /// public class ExporterParser : IArgumentParser> @@ -59,6 +60,9 @@ public bool TryParse(string raw, out IReadOnlySet result) case "pagefind": _ = set.Add(Exporter.Pagefind); break; + case "gitdiff": + _ = set.Add(Exporter.GitDiff); + break; case "none": break; case "default": @@ -71,7 +75,7 @@ public bool TryParse(string raw, out IReadOnlySet result) break; default: throw new ArgumentException( - $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, pagefind, default, metadata, none."); + $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, pagefind, gitdiff, default, metadata, none."); } } result = set; diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index b492828af7..3f15bf8fd5 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -68,13 +68,16 @@ public async Task Build( pathPrefix ??= githubActionsService.GetInput("prefix"); - var runningOnCi = _env.IsRunningOnCI; BuildContext context; canonicalBaseUri ??= new Uri("https://docs-v3-preview.elastic.dev"); + var runningOnCi = _env.IsRunningOnCI; if (runningOnCi) { + if (!exporters.Contains(Exporter.GitDiff)) + exporters = new HashSet(exporters) { Exporter.GitDiff }; + _logger.LogInformation("Build running on CI, forcing a full rebuild of the destination folder"); force = true; } diff --git a/tests/Elastic.Markdown.Tests/Exporters/ChangedPagesMapperTests.cs b/tests/Elastic.Markdown.Tests/Exporters/ChangedPagesMapperTests.cs new file mode 100644 index 0000000000..fb433eb2d0 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Exporters/ChangedPagesMapperTests.cs @@ -0,0 +1,102 @@ +// 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 AwesomeAssertions; +using Elastic.Markdown.Exporters.GitDiff; + +namespace Elastic.Markdown.Tests.Exporters; + +public class ChangedPagesMapperTests +{ + private static readonly IReadOnlyDictionary> EmptyIncludeIndex = + new Dictionary>(); + + private static readonly Dictionary SamplePages = new(StringComparer.OrdinalIgnoreCase) + { + ["guides/start.md"] = new("/preview/guides/start", "Get started"), + ["reference/index.md"] = new("/preview/reference", "Reference"), + ["reference/page.md"] = new("/preview/reference/page", "A page"), + }; + + [Fact] + public void Map_DirectPageChange_ReturnsPageWithUrlAndTitle() + { + var changes = new[] { new SourceFileChange("docs/guides/start.md", SourceFileChangeType.Modified) }; + + var export = ChangedPagesMapper.Map("origin/main", "docs", SamplePages, EmptyIncludeIndex, changes); + + export.Pages.Should().ContainSingle(); + export.Pages[0].SourcePath.Should().Be("guides/start.md"); + export.Pages[0].Url.Should().Be("/preview/guides/start"); + export.Pages[0].Title.Should().Be("Get started"); + export.Pages[0].Change.Should().Be("modified"); + export.Pages[0].IncludedFrom.Should().BeEmpty(); + } + + [Fact] + public void Map_SnippetChange_ReturnsPagesThatIncludeIt() + { + var includeIndex = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["_snippets/shared.md"] = ["guides/start.md", "reference/page.md"] + }; + var changes = new[] { new SourceFileChange("docs/_snippets/shared.md", SourceFileChangeType.Modified) }; + + var export = ChangedPagesMapper.Map("origin/main", "docs", SamplePages, includeIndex, changes); + + export.Pages.Should().HaveCount(2); + export.Pages.Should().OnlyContain(p => p.Change == "modified"); + export.Pages.Should().AllSatisfy(p => p.IncludedFrom.Should().Equal(["_snippets/shared.md"])); + } + + [Fact] + public void Map_ConfigFileChange_SetsConfigChangedWithoutPages() + { + var changes = new[] { new SourceFileChange("docs/docset.yml", SourceFileChangeType.Modified) }; + + var export = ChangedPagesMapper.Map("origin/main", "docs", SamplePages, EmptyIncludeIndex, changes); + + export.ConfigChanged.Should().BeTrue(); + export.Pages.Should().BeEmpty(); + } + + [Fact] + public void Map_DeletedPage_AddsDeletedEntry() + { + var changes = new[] { new SourceFileChange("docs/reference/page.md", SourceFileChangeType.Deleted) }; + + var export = ChangedPagesMapper.Map("origin/main", "docs", SamplePages, EmptyIncludeIndex, changes); + + export.Deleted.Should().ContainSingle(d => d.SourcePath == "reference/page.md"); + export.Pages.Should().BeEmpty(); + } + + [Fact] + public void Map_RenamedPage_AddsDeletedOldPathAndNewPage() + { + var pages = new Dictionary(SamplePages, StringComparer.OrdinalIgnoreCase) + { + ["guides/new-start.md"] = new("/preview/guides/new-start", "Get started") + }; + var changes = new[] + { + new SourceFileChange("docs/guides/start.md", SourceFileChangeType.Renamed, "docs/guides/new-start.md") + }; + + var export = ChangedPagesMapper.Map("origin/main", "docs", pages, EmptyIncludeIndex, changes); + + export.Deleted.Should().ContainSingle(d => d.SourcePath == "guides/start.md"); + export.Pages.Should().ContainSingle(p => p.SourcePath == "guides/new-start.md" && p.Change == "renamed"); + } + + [Fact] + public void Map_IndexPage_UsesNavigationUrlWithoutIndexSuffix() + { + var changes = new[] { new SourceFileChange("docs/reference/index.md", SourceFileChangeType.Modified) }; + + var export = ChangedPagesMapper.Map("origin/main", "docs", SamplePages, EmptyIncludeIndex, changes); + + export.Pages.Should().ContainSingle(p => p.Url == "/preview/reference"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs b/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs new file mode 100644 index 0000000000..600aba4116 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs @@ -0,0 +1,56 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Markdown.Exporters.GitDiff; + +namespace Elastic.Markdown.Tests.Exporters; + +public class GitChangedFileSourceTests +{ + [Fact] + public void ParseNameStatus_ParsesModifiedAndRenamedEntries() + { + var output = "M\u0000docs/guides/start.md\u0000R100\u0000docs/old.md\u0000docs/new.md\u0000"; + + var changes = GitChangedFileSource.ParseNameStatus(output); + + changes.Should().HaveCount(2); + changes[0].Path.Should().Be("docs/guides/start.md"); + changes[0].ChangeType.Should().Be(SourceFileChangeType.Modified); + changes[1].Path.Should().Be("docs/old.md"); + changes[1].NewPath.Should().Be("docs/new.md"); + changes[1].ChangeType.Should().Be(SourceFileChangeType.Renamed); + } + + [Fact] + public void IntegrationChangedFileSource_ReadsCiEnvironmentVariables() + { + var env = new DictionaryEnvironmentVariables(new Dictionary + { + ["MODIFIED_FILES"] = "docs/guides/start.md docs/other.md", + ["ADDED_FILES"] = "docs/new.md", + ["DELETED_FILES"] = "docs/removed.md", + ["RENAMED_FILES"] = "docs/old.md:docs/renamed.md" + }); + + var result = IntegrationChangedFileSource.GetChanges("docs", env, "origin/main"); + + result.Base.Should().Be("origin/main"); + result.Changes.Should().HaveCount(4); + result.Changes.Should().Contain(c => c.Path == "docs/guides/start.md" && c.ChangeType == SourceFileChangeType.Modified); + result.Changes.Should().Contain(c => c.Path == "docs/new.md" && c.ChangeType == SourceFileChangeType.Added); + result.Changes.Should().Contain(c => c.Path == "docs/removed.md" && c.ChangeType == SourceFileChangeType.Deleted); + result.Changes.Should().Contain(c => c.Path == "docs/old.md" && c.NewPath == "docs/renamed.md"); + } + + private sealed class DictionaryEnvironmentVariables(Dictionary values) : IEnvironmentVariables + { + public string? GetEnvironmentVariable(string name) => + values.TryGetValue(name, out var value) ? value : null; + + public bool IsRunningOnCI => true; + } +} diff --git a/tests/Elastic.Markdown.Tests/Exporters/GitDiffMarkdownExporterTests.cs b/tests/Elastic.Markdown.Tests/Exporters/GitDiffMarkdownExporterTests.cs new file mode 100644 index 0000000000..8fb864a60f --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Exporters/GitDiffMarkdownExporterTests.cs @@ -0,0 +1,60 @@ +// 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 System.Text.Json; +using AwesomeAssertions; +using Elastic.Documentation.GitDiff; +using Elastic.Documentation.Serialization; +using Elastic.Markdown.Exporters.GitDiff; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elastic.Markdown.Tests.Exporters; + +public class GitDiffMarkdownExporterTests +{ + [Fact] + public async Task FinishExportAsync_WithoutExportAsync_DoesNotWriteChangedPagesFile() + { + const string outputPath = "/repo/.artifacts/docs/html"; + var fileSystem = new MockFileSystem(); + var outputFolder = fileSystem.DirectoryInfo.New(outputPath); + var exporter = new GitDiffMarkdownExporter(NullLoggerFactory.Instance); + + var result = await exporter.FinishExportAsync(outputFolder, TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + fileSystem.File.Exists($"{outputPath}/{ChangedPagesExportFile.FileName}").Should().BeFalse(); + } + + [Fact] + public void Serialize_WritesSnakeCaseJson() + { + var export = new ChangedPagesExport + { + Base = "origin/main", + ConfigChanged = false, + Pages = + [ + new ChangedPageEntry + { + SourcePath = "guides/start.md", + Url = "/preview/guides/start", + Title = "Get started", + Change = "modified", + IncludedFrom = [] + } + ], + Deleted = [] + }; + + var json = ChangedPagesExportFile.Serialize(export); + using var document = JsonDocument.Parse(json); + + document.RootElement.GetProperty("base").GetString().Should().Be("origin/main"); + document.RootElement.GetProperty("config_changed").GetBoolean().Should().BeFalse(); + document.RootElement.GetProperty("pages")[0].GetProperty("source_path").GetString().Should().Be("guides/start.md"); + document.RootElement.GetProperty("pages")[0].GetProperty("included_from").GetArrayLength().Should().Be(0); + } +} diff --git a/tests/Elastic.Markdown.Tests/Exporters/GitDiffPathNormalizationTests.cs b/tests/Elastic.Markdown.Tests/Exporters/GitDiffPathNormalizationTests.cs new file mode 100644 index 0000000000..fa43fab7ad --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Exporters/GitDiffPathNormalizationTests.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 AwesomeAssertions; +using Elastic.Markdown.Exporters.GitDiff; + +namespace Elastic.Markdown.Tests.Exporters; + +public class GitDiffPathNormalizationTests +{ + [Theory] + [InlineData("docs/guides/start.md", "docs", "guides/start.md")] + [InlineData("guides/start.md", "", "guides/start.md")] + [InlineData("docs/reference/index.md", "docs", "reference/index.md")] + public void TryToDocsetRelative_StripsDocsetPrefix(string repoPath, string prefix, string expected) + { + GitDiffPathNormalization.TryToDocsetRelative(repoPath, prefix, out var relative).Should().BeTrue(); + relative.Should().Be(expected); + } + + [Fact] + public void TryToDocsetRelative_RejectsPathsOutsideDocset() + { + GitDiffPathNormalization.TryToDocsetRelative("docs/guides/start.md", "docs", out _).Should().BeTrue(); + GitDiffPathNormalization.TryToDocsetRelative("other/page.md", "docs", out _).Should().BeFalse(); + GitDiffPathNormalization.TryToDocsetRelative("docs", "docs", out var root).Should().BeTrue(); + root.Should().BeEmpty(); + } + + [Fact] + public void IsMarkdownPagePath_SkipsSnippetFolders() + { + GitDiffPathNormalization.IsMarkdownPagePath("_snippets/foo.md").Should().BeFalse(); + GitDiffPathNormalization.IsMarkdownPagePath("guides/_snippets/foo.md").Should().BeFalse(); + GitDiffPathNormalization.IsMarkdownPagePath("guides/page.md").Should().BeTrue(); + } +} From b16c9b33ba5c25087b64ae67ed1d9deb3df0dc1d Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Aug 2026 22:33:42 +0200 Subject: [PATCH 2/7] Fix HashSet collection expression syntax in ChangedPagesMapper new(comparer)[...] is invalid C#. Pass the collection and comparer as separate constructor arguments. --- src/Elastic.Markdown/Exporters/GitDiff/ChangedPagesMapper.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Markdown/Exporters/GitDiff/ChangedPagesMapper.cs b/src/Elastic.Markdown/Exporters/GitDiff/ChangedPagesMapper.cs index 688353f996..5e2d637ad3 100644 --- a/src/Elastic.Markdown/Exporters/GitDiff/ChangedPagesMapper.cs +++ b/src/Elastic.Markdown/Exporters/GitDiff/ChangedPagesMapper.cs @@ -8,7 +8,7 @@ namespace Elastic.Markdown.Exporters.GitDiff; internal static class ChangedPagesMapper { - private static readonly HashSet ConfigFileNames = new(StringComparer.OrdinalIgnoreCase) + private static readonly HashSet ConfigFileNames = new( [ "docset.yml", "_docset.yml", @@ -21,7 +21,8 @@ internal static class ChangedPagesMapper "legacy-url-mappings.yml", "assembler.yml", "search.yml", - ]; + ], + StringComparer.OrdinalIgnoreCase); public static ChangedPagesExport Map( string diffBase, From a5cc678769c28820fe54fae19bc3c27589395029 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Aug 2026 22:47:05 +0200 Subject: [PATCH 3/7] Test git-diff exporter with mocked git stdout Cover GetChanges, CI file lists, and include mapping without a real .git folder. --- .../Exporters/GitDiff/GitChangedFileSource.cs | 8 +- .../GitDiff/GitDiffMarkdownExporter.cs | 22 ++- .../Exporters/ChangedPagesMapperTests.cs | 22 +++ .../Exporters/GitChangedFileSourceTests.cs | 143 +++++++++++++++++- .../Exporters/GitDiffMarkdownExporterTests.cs | 89 ++++++++++- .../GitDiffPathNormalizationTests.cs | 6 + tests/Elastic.Markdown.Tests/TestHelpers.cs | 8 + 7 files changed, 284 insertions(+), 14 deletions(-) diff --git a/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs b/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs index 6066ff8f62..e370362186 100644 --- a/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs +++ b/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs @@ -13,7 +13,8 @@ internal sealed class GitChangedFileSource( ILoggerFactory logFactory, IDirectoryInfo checkoutDirectory, string docsetPrefix, - IEnvironmentVariables environment + IEnvironmentVariables environment, + Func? gitCommand = null ) { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -109,7 +110,10 @@ internal static IReadOnlyList ParseNameStatus(string output) return changes; } - private string GitCommand(params string[] args) + private string GitCommand(params string[] args) => + gitCommand is not null ? gitCommand(args) : RunGitProcess(args); + + private string RunGitProcess(string[] args) { try { diff --git a/src/Elastic.Markdown/Exporters/GitDiff/GitDiffMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/GitDiff/GitDiffMarkdownExporter.cs index 3d90153a13..d25289f0fa 100644 --- a/src/Elastic.Markdown/Exporters/GitDiff/GitDiffMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/GitDiff/GitDiffMarkdownExporter.cs @@ -13,14 +13,27 @@ namespace Elastic.Markdown.Exporters.GitDiff; -public sealed class GitDiffMarkdownExporter(ILoggerFactory logFactory) : IMarkdownExporter +public sealed class GitDiffMarkdownExporter : IMarkdownExporter { - private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly ILoggerFactory _logFactory; + private readonly ILogger _logger; + private readonly Func? _gitCommand; private readonly ConcurrentDictionary _builtPages = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary> _includeIndex = new(StringComparer.OrdinalIgnoreCase); private BuildContext? _buildContext; private string _docsetPrefix = string.Empty; + public GitDiffMarkdownExporter(ILoggerFactory logFactory) : this(logFactory, null) + { + } + + internal GitDiffMarkdownExporter(ILoggerFactory logFactory, Func? gitCommand) + { + _logFactory = logFactory; + _gitCommand = gitCommand; + _logger = logFactory.CreateLogger(); + } + public ValueTask StartAsync(Cancel ctx = default) => ValueTask.CompletedTask; public ValueTask StopAsync(Cancel ctx = default) => ValueTask.CompletedTask; @@ -61,10 +74,11 @@ public async ValueTask FinishExportAsync(IDirectoryInfo outputFolder, Canc } var changeResult = new GitChangedFileSource( - logFactory, + _logFactory, _buildContext.DocumentationCheckoutDirectory, _docsetPrefix, - _buildContext.Environment + _buildContext.Environment, + _gitCommand ).GetChanges(); var includeIndex = _includeIndex.ToDictionary( diff --git a/tests/Elastic.Markdown.Tests/Exporters/ChangedPagesMapperTests.cs b/tests/Elastic.Markdown.Tests/Exporters/ChangedPagesMapperTests.cs index fb433eb2d0..fb84ae2fe9 100644 --- a/tests/Elastic.Markdown.Tests/Exporters/ChangedPagesMapperTests.cs +++ b/tests/Elastic.Markdown.Tests/Exporters/ChangedPagesMapperTests.cs @@ -99,4 +99,26 @@ public void Map_IndexPage_UsesNavigationUrlWithoutIndexSuffix() export.Pages.Should().ContainSingle(p => p.Url == "/preview/reference"); } + + [Fact] + public void Map_AddedPage_UsesAddedChangeLabel() + { + var changes = new[] { new SourceFileChange("docs/guides/start.md", SourceFileChangeType.Added) }; + + var export = ChangedPagesMapper.Map("origin/main", "docs", SamplePages, EmptyIncludeIndex, changes); + + export.Pages.Should().ContainSingle(p => p.SourcePath == "guides/start.md" && p.Change == "added"); + } + + [Fact] + public void Map_PathOutsideDocset_IsIgnored() + { + var changes = new[] { new SourceFileChange("README.md", SourceFileChangeType.Modified) }; + + var export = ChangedPagesMapper.Map("origin/main", "docs", SamplePages, EmptyIncludeIndex, changes); + + export.Pages.Should().BeEmpty(); + export.Deleted.Should().BeEmpty(); + export.ConfigChanged.Should().BeFalse(); + } } diff --git a/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs b/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs index 600aba4116..f727f3c9a4 100644 --- a/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs +++ b/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs @@ -2,9 +2,10 @@ // 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.Markdown.Exporters.GitDiff; +using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Markdown.Tests.Exporters; @@ -25,10 +26,33 @@ public void ParseNameStatus_ParsesModifiedAndRenamedEntries() changes[1].ChangeType.Should().Be(SourceFileChangeType.Renamed); } + [Fact] + public void ParseNameStatus_ParsesAddedDeletedAndCopiedEntries() + { + var output = "A\u0000docs/new.md\u0000D\u0000docs/gone.md\u0000C100\u0000docs/src.md\u0000docs/copy.md\u0000"; + + var changes = GitChangedFileSource.ParseNameStatus(output); + + changes.Should().HaveCount(3); + changes[0].ChangeType.Should().Be(SourceFileChangeType.Added); + changes[1].ChangeType.Should().Be(SourceFileChangeType.Deleted); + changes[2].ChangeType.Should().Be(SourceFileChangeType.Renamed); + changes[2].Path.Should().Be("docs/src.md"); + changes[2].NewPath.Should().Be("docs/copy.md"); + } + + [Fact] + public void ParseNameStatus_IgnoresTruncatedRename() + { + var changes = GitChangedFileSource.ParseNameStatus("R100\u0000docs/old.md\u0000"); + + changes.Should().BeEmpty(); + } + [Fact] public void IntegrationChangedFileSource_ReadsCiEnvironmentVariables() { - var env = new DictionaryEnvironmentVariables(new Dictionary + var env = new FakeEnvironmentVariables(new Dictionary { ["MODIFIED_FILES"] = "docs/guides/start.md docs/other.md", ["ADDED_FILES"] = "docs/new.md", @@ -39,18 +63,123 @@ public void IntegrationChangedFileSource_ReadsCiEnvironmentVariables() var result = IntegrationChangedFileSource.GetChanges("docs", env, "origin/main"); result.Base.Should().Be("origin/main"); - result.Changes.Should().HaveCount(4); + result.Changes.Should().HaveCount(5); result.Changes.Should().Contain(c => c.Path == "docs/guides/start.md" && c.ChangeType == SourceFileChangeType.Modified); result.Changes.Should().Contain(c => c.Path == "docs/new.md" && c.ChangeType == SourceFileChangeType.Added); result.Changes.Should().Contain(c => c.Path == "docs/removed.md" && c.ChangeType == SourceFileChangeType.Deleted); result.Changes.Should().Contain(c => c.Path == "docs/old.md" && c.NewPath == "docs/renamed.md"); } - private sealed class DictionaryEnvironmentVariables(Dictionary values) : IEnvironmentVariables + [Fact] + public void IntegrationChangedFileSource_DropsPathsOutsideDocset() + { + var env = new FakeEnvironmentVariables(new Dictionary + { + ["MODIFIED_FILES"] = "docs/page.md README.md", + ["RENAMED_FILES"] = "src/old.md:src/new.md" + }); + + var result = IntegrationChangedFileSource.GetChanges("docs", env, "ci"); + + result.Changes.Should().ContainSingle(c => c.Path == "docs/page.md"); + } + + [Fact] + public void GetChanges_UsesDocsDiffBaseAndParsesGitStdout() + { + var calls = new List(); + string Git(string[] args) + { + calls.Add(args); + return "M\u0000docs/guides/start.md\u0000A\u0000docs/new.md\u0000"; + } + + var result = CreateSource( + new FakeEnvironmentVariables(new Dictionary { ["DOCS_DIFF_BASE"] = "origin/main" }), + Git + ).GetChanges(); + + result.Base.Should().Be("origin/main"); + result.Changes.Should().HaveCount(2); + calls.Should().ContainSingle(); + calls[0].Should().Equal("diff", "--name-status", "-z", "origin/main", "HEAD", "--", "./docs"); + } + + [Fact] + public void GetChanges_PrefixesGithubBaseRef() + { + string Git(string[] args) => + args[0] == "diff" ? "M\u0000docs/page.md\u0000" : string.Empty; + + var result = CreateSource( + new FakeEnvironmentVariables(new Dictionary { ["GITHUB_BASE_REF"] = "main" }), + Git + ).GetChanges(); + + result.Base.Should().Be("origin/main"); + result.Changes.Should().ContainSingle(c => c.Path == "docs/page.md"); + } + + [Fact] + public void GetChanges_SkipsGitWhenCiFileListIsSet() + { + var result = CreateSource( + new FakeEnvironmentVariables(new Dictionary + { + ["MODIFIED_FILES"] = "docs/page.md", + ["DOCS_DIFF_BASE"] = "origin/main" + }), + static _ => throw new InvalidOperationException("git should not run when a CI file list is set") + ).GetChanges(); + + result.Base.Should().Be("origin/main"); + result.Changes.Should().ContainSingle(c => c.Path == "docs/page.md"); + } + + [Fact] + public void GetChanges_EmptyGitOutputYieldsNoChanges() { - public string? GetEnvironmentVariable(string name) => - values.TryGetValue(name, out var value) ? value : null; + var result = CreateSource( + new FakeEnvironmentVariables(new Dictionary { ["DOCS_DIFF_BASE"] = "origin/main" }), + static _ => string.Empty + ).GetChanges(); - public bool IsRunningOnCI => true; + result.Changes.Should().BeEmpty(); + } + + [Fact] + public void GetChanges_OmitsPathspecWhenDocsetPrefixIsEmpty() + { + string[]? diffArgs = null; + string Git(string[] args) + { + if (args[0] == "diff") + diffArgs = args; + return string.Empty; + } + + _ = CreateSource( + new FakeEnvironmentVariables(new Dictionary { ["DOCS_DIFF_BASE"] = "HEAD~1" }), + Git, + docsetPrefix: "" + ).GetChanges(); + + diffArgs.Should().Equal("diff", "--name-status", "-z", "HEAD~1", "HEAD"); + } + + private static GitChangedFileSource CreateSource( + FakeEnvironmentVariables environment, + Func gitCommand, + string docsetPrefix = "docs" + ) + { + var fileSystem = new MockFileSystem(); + return new GitChangedFileSource( + NullLoggerFactory.Instance, + fileSystem.DirectoryInfo.New("/repo"), + docsetPrefix, + environment, + gitCommand + ); } } diff --git a/tests/Elastic.Markdown.Tests/Exporters/GitDiffMarkdownExporterTests.cs b/tests/Elastic.Markdown.Tests/Exporters/GitDiffMarkdownExporterTests.cs index 8fb864a60f..a6ecb57aa5 100644 --- a/tests/Elastic.Markdown.Tests/Exporters/GitDiffMarkdownExporterTests.cs +++ b/tests/Elastic.Markdown.Tests/Exporters/GitDiffMarkdownExporterTests.cs @@ -5,14 +5,17 @@ using System.IO.Abstractions.TestingHelpers; using System.Text.Json; using AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; using Elastic.Documentation.GitDiff; using Elastic.Documentation.Serialization; using Elastic.Markdown.Exporters.GitDiff; +using Elastic.Markdown.IO; using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Markdown.Tests.Exporters; -public class GitDiffMarkdownExporterTests +public class GitDiffMarkdownExporterTests(ITestOutputHelper output) { [Fact] public async Task FinishExportAsync_WithoutExportAsync_DoesNotWriteChangedPagesFile() @@ -57,4 +60,88 @@ public void Serialize_WritesSnakeCaseJson() document.RootElement.GetProperty("pages")[0].GetProperty("source_path").GetString().Should().Be("guides/start.md"); document.RootElement.GetProperty("pages")[0].GetProperty("included_from").GetArrayLength().Should().Be(0); } + + [Fact] + public async Task FinishExportAsync_WritesChangedPageFromCiFileList() + { + var export = await GenerateChangedPages( + environment: new FakeEnvironmentVariables(new Dictionary + { + ["MODIFIED_FILES"] = "docs/index.md", + ["DOCS_DIFF_BASE"] = "origin/main" + }), + gitCommand: static _ => throw new InvalidOperationException("git should not run when a CI file list is set") + ); + + export.Base.Should().Be("origin/main"); + export.Pages.Should().ContainSingle(p => p.SourcePath == "index.md" && p.Change == "modified"); + export.Pages[0].Url.Should().NotBeNullOrEmpty(); + export.Pages[0].Title.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task FinishExportAsync_MapsSnippetGitDiffOntoIncludingPage() + { + var export = await GenerateChangedPages( + environment: new FakeEnvironmentVariables(new Dictionary + { + ["DOCS_DIFF_BASE"] = "origin/main" + }), + gitCommand: static args => args[0] == "diff" + ? "M\u0000docs/_snippets/shared.md\u0000" + : string.Empty + ); + + export.Pages.Should().ContainSingle(p => p.SourcePath == "index.md"); + export.Pages[0].IncludedFrom.Should().Contain("_snippets/shared.md"); + export.Pages[0].Change.Should().Be("modified"); + } + + private async Task GenerateChangedPages( + FakeEnvironmentVariables environment, + Func gitCommand + ) + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["docs/docset.yml"] = new(""" + project: test + toc: + - file: index.md + """), + ["docs/index.md"] = new(""" + # Get started + + :::{include} _snippets/shared.md + ::: + """), + ["docs/_snippets/shared.md"] = new("shared text") + }, new MockFileSystemOptions + { + CurrentDirectory = Paths.WorkingDirectoryRoot.FullName + }); + + var collector = new TestDiagnosticsCollector(output); + _ = collector.StartAsync(TestContext.Current.CancellationToken); + var context = new BuildContext( + collector, + TestHelpers.CreateDocumentationFileSystem(fileSystem), + TestHelpers.CreateConfigurationContext(fileSystem), + environment + ) + { + Force = true + }; + var exporter = new GitDiffMarkdownExporter(NullLoggerFactory.Instance, gitCommand); + var set = new DocumentationSet(context, NullLoggerFactory.Instance, new TestCrossLinkResolver()); + var generator = new DocumentationGenerator(set, NullLoggerFactory.Instance, markdownExporters: [exporter]); + + _ = await generator.GenerateAll(TestContext.Current.CancellationToken); + _ = await exporter.FinishExportAsync(context.OutputDirectory, TestContext.Current.CancellationToken); + + var json = fileSystem.File.ReadAllText( + Path.Join(context.OutputDirectory.FullName, ChangedPagesExportFile.FileName)); + return JsonSerializer.Deserialize(json, SourceGenerationContext.Default.ChangedPagesExport) + ?? throw new InvalidOperationException("changed-pages.json did not deserialize"); + } } diff --git a/tests/Elastic.Markdown.Tests/Exporters/GitDiffPathNormalizationTests.cs b/tests/Elastic.Markdown.Tests/Exporters/GitDiffPathNormalizationTests.cs index fa43fab7ad..0ac9557f38 100644 --- a/tests/Elastic.Markdown.Tests/Exporters/GitDiffPathNormalizationTests.cs +++ b/tests/Elastic.Markdown.Tests/Exporters/GitDiffPathNormalizationTests.cs @@ -35,4 +35,10 @@ public void IsMarkdownPagePath_SkipsSnippetFolders() GitDiffPathNormalization.IsMarkdownPagePath("guides/_snippets/foo.md").Should().BeFalse(); GitDiffPathNormalization.IsMarkdownPagePath("guides/page.md").Should().BeTrue(); } + + [Fact] + public void Normalize_ConvertsWindowsSlashesAndTrimsDotSegments() + { + GitDiffPathNormalization.Normalize(@".\docs\page.md").Should().Be("docs/page.md"); + } } diff --git a/tests/Elastic.Markdown.Tests/TestHelpers.cs b/tests/Elastic.Markdown.Tests/TestHelpers.cs index 07296fc9c4..9796bd4f0e 100644 --- a/tests/Elastic.Markdown.Tests/TestHelpers.cs +++ b/tests/Elastic.Markdown.Tests/TestHelpers.cs @@ -144,3 +144,11 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS }; } } + +internal sealed class FakeEnvironmentVariables(Dictionary values) : IEnvironmentVariables +{ + public string? GetEnvironmentVariable(string name) => + values.TryGetValue(name, out var value) ? value : null; + + public bool IsRunningOnCI => true; +} From 6729e0cdce0867a0e5bdddab7c8a081020641188 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Aug 2026 22:49:16 +0200 Subject: [PATCH 4/7] Fix IDE0062 in GitChangedFileSource tests dotnet format --verify-no-changes treats a non-static local function as a lint failure. --- .../Exporters/GitChangedFileSourceTests.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs b/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs index f727f3c9a4..231a2e339a 100644 --- a/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs +++ b/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs @@ -108,12 +108,9 @@ string Git(string[] args) [Fact] public void GetChanges_PrefixesGithubBaseRef() { - string Git(string[] args) => - args[0] == "diff" ? "M\u0000docs/page.md\u0000" : string.Empty; - var result = CreateSource( new FakeEnvironmentVariables(new Dictionary { ["GITHUB_BASE_REF"] = "main" }), - Git + static args => args[0] == "diff" ? "M\u0000docs/page.md\u0000" : string.Empty ).GetChanges(); result.Base.Should().Be("origin/main"); From 8cdf7789ab2498a298771a623d57c2cc4c15a7fc Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Aug 2026 22:56:03 +0200 Subject: [PATCH 5/7] Add git-diff.md to the exporters table of contents The page was linked from index.md but not listed in _docset.yml, so --strict CI failed. --- docs/_docset.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/_docset.yml b/docs/_docset.yml index 0f27dcbb98..24bc9c71d6 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -204,6 +204,7 @@ toc: - file: llm.md - file: okf.md - file: plain-text.md + - file: git-diff.md - folder: release-notes children: - file: index.md From f113f9c228cca45679210fe327b0d6448f39132d Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Aug 2026 23:04:48 +0200 Subject: [PATCH 6/7] Fall back to HEAD^1 when named git bases are missing Named branches often do not exist in local clones or shallow checkouts. The first parent still gives a usable diff. --- docs/data/exporters/git-diff.md | 1 + .../Exporters/GitDiff/GitChangedFileSource.cs | 11 ++++- .../Exporters/GitChangedFileSourceTests.cs | 42 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/docs/data/exporters/git-diff.md b/docs/data/exporters/git-diff.md index 31556dc9b2..9706e544ca 100644 --- a/docs/data/exporters/git-diff.md +++ b/docs/data/exporters/git-diff.md @@ -54,6 +54,7 @@ Otherwise it resolves the git diff base in this order: 1. `DOCS_DIFF_BASE` environment variable 2. `GITHUB_BASE_REF` → `origin/` 3. `main`, then `master`, then `origin/HEAD` +4. `HEAD^1` when that first parent exists Then it runs `git diff --name-status -z HEAD`. diff --git a/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs b/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs index e370362186..dadba01c2f 100644 --- a/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs +++ b/src/Elastic.Markdown/Exporters/GitDiff/GitChangedFileSource.cs @@ -46,21 +46,28 @@ private string ResolveDiffBase() foreach (var candidate in new[] { "main", "master" }) { var output = GitCommand("merge-base", "-a", "HEAD", candidate); - if (output.Length > 0 && !output.StartsWith("fatal", StringComparison.Ordinal)) + if (IsUsableGitOutput(output)) return candidate; } var originHead = GitCommand("symbolic-ref", "refs/remotes/origin/HEAD"); - if (originHead.Length > 0 && !originHead.StartsWith("fatal", StringComparison.Ordinal)) + if (IsUsableGitOutput(originHead)) { var parts = originHead.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length >= 2) return $"{parts[^2]}/{parts[^1]}"; } + var headParent = GitCommand("rev-parse", "--verify", "HEAD^1"); + if (IsUsableGitOutput(headParent)) + return "HEAD^1"; + return "main"; } + private static bool IsUsableGitOutput(string output) => + output.Length > 0 && !output.StartsWith("fatal", StringComparison.Ordinal); + private IReadOnlyList RunGitDiff(string diffBase) { var lookupPath = GitDiffPathNormalization.Normalize(docsetPrefix); diff --git a/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs b/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs index 231a2e339a..5229c58b82 100644 --- a/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs +++ b/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs @@ -164,6 +164,48 @@ string Git(string[] args) diffArgs.Should().Equal("diff", "--name-status", "-z", "HEAD~1", "HEAD"); } + [Fact] + public void GetChanges_FallsBackToHeadParentWhenNamedBasesAreMissing() + { + var calls = new List(); + string Git(string[] args) + { + calls.Add(args); + if (args is ["rev-parse", "--verify", "HEAD^1"]) + return "abc123"; + if (args[0] == "diff") + return "M\u0000docs/page.md\u0000"; + return string.Empty; + } + + var result = CreateSource(new FakeEnvironmentVariables([]), Git).GetChanges(); + + result.Base.Should().Be("HEAD^1"); + result.Changes.Should().ContainSingle(c => c.Path == "docs/page.md"); + calls.Any(c => c.SequenceEqual(new[] { "rev-parse", "--verify", "HEAD^1" })).Should().BeTrue(); + calls.Any(c => c.SequenceEqual(new[] { "diff", "--name-status", "-z", "HEAD^1", "HEAD", "--", "./docs" })).Should().BeTrue(); + } + + [Fact] + public void GetChanges_PrefersMainOverHeadParent() + { + var calls = new List(); + string Git(string[] args) + { + calls.Add(args); + if (args is ["merge-base", "-a", "HEAD", "main"]) + return "def456"; + if (args[0] == "diff") + return "M\u0000docs/page.md\u0000"; + return string.Empty; + } + + var result = CreateSource(new FakeEnvironmentVariables([]), Git).GetChanges(); + + result.Base.Should().Be("main"); + calls.Any(c => c.SequenceEqual(new[] { "rev-parse", "--verify", "HEAD^1" })).Should().BeFalse(); + } + private static GitChangedFileSource CreateSource( FakeEnvironmentVariables environment, Func gitCommand, From 403b200d5b80069454b57d402c9b83731c54f4ff Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Aug 2026 23:06:49 +0200 Subject: [PATCH 7/7] Use collection expressions in GitChangedFileSource tests dotnet format --verify-no-changes treats new[] as IDE0300. --- .../Exporters/GitChangedFileSourceTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs b/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs index 5229c58b82..c7c87d22d2 100644 --- a/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs +++ b/tests/Elastic.Markdown.Tests/Exporters/GitChangedFileSourceTests.cs @@ -182,8 +182,8 @@ string Git(string[] args) result.Base.Should().Be("HEAD^1"); result.Changes.Should().ContainSingle(c => c.Path == "docs/page.md"); - calls.Any(c => c.SequenceEqual(new[] { "rev-parse", "--verify", "HEAD^1" })).Should().BeTrue(); - calls.Any(c => c.SequenceEqual(new[] { "diff", "--name-status", "-z", "HEAD^1", "HEAD", "--", "./docs" })).Should().BeTrue(); + calls.Any(c => c.SequenceEqual(["rev-parse", "--verify", "HEAD^1"])).Should().BeTrue(); + calls.Any(c => c.SequenceEqual(["diff", "--name-status", "-z", "HEAD^1", "HEAD", "--", "./docs"])).Should().BeTrue(); } [Fact] @@ -203,7 +203,7 @@ string Git(string[] args) var result = CreateSource(new FakeEnvironmentVariables([]), Git).GetChanges(); result.Base.Should().Be("main"); - calls.Any(c => c.SequenceEqual(new[] { "rev-parse", "--verify", "HEAD^1" })).Should().BeFalse(); + calls.Any(c => c.SequenceEqual(["rev-parse", "--verify", "HEAD^1"])).Should().BeFalse(); } private static GitChangedFileSource CreateSource(