Skip to content

Commit c6e4a5c

Browse files
authored
diff validate improvements (#1782)
* Allow relative path handling in diff validation and enhance root determination - Updated `ValidateRedirects` method to support relative paths. - Improved handling of source directory root in diff validation. - Fixed path normalization in `IntegrationGitRepositoryTracker`. * Do not pop unrelated changes * Refactor `RedirectFile` initialization and enhance `IRepositoryTracker` for improved redirect handling - Simplified `RedirectFile` creation logic and constructor overloading. - Changed `IRepositoryTracker` to return a `IReadOnlyCollection` for consistency. - Enhanced path normalization across repository trackers. - Adjusted dependent tests and updated related YAML documentation. * Exclude `_snippets` folder from missing redirects detection in `DiffCommands` * Add HasParent extension method on IFileInfo and IDirectoryInfo * Only consider markdown file changes * use string.Equals * update logging * update filter, include markdowns without parent _snippet folder * Add StringComparison to file/directory extensions, with hearistic for default for IsSubPathOf * update tests * Address a bunch of code-analysis quick fixes (#1761)
1 parent f82eee9 commit c6e4a5c

File tree

10 files changed

+207
-63
lines changed

10 files changed

+207
-63
lines changed

docs/contribute/redirects.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ This example strips all anchors from the source page.
8686
Any remaining links resolving to anchors on `7th-page.md` will fail link validation.
8787

8888
```yaml
89-
redirects
89+
redirects:
9090
'testing/redirects/7th-page.md':
9191
to: 'testing/redirects/5th-page.md'
9292
anchors: '!'

src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,10 @@ public ConfigurationFile(IDocumentationSetContext context, VersionsConfiguration
7373
return;
7474
}
7575

76-
var sourceFile = context.ConfigurationPath;
77-
var redirectFileName = sourceFile.Name.StartsWith('_') ? "_redirects.yml" : "redirects.yml";
78-
var redirectFileInfo = sourceFile.FileSystem.FileInfo.New(Path.Combine(sourceFile.Directory!.FullName, redirectFileName));
79-
var redirectFile = new RedirectFile(redirectFileInfo, _context);
76+
var redirectFile = new RedirectFile(_context);
8077
Redirects = redirectFile.Redirects;
8178

79+
var sourceFile = context.ConfigurationPath;
8280
var reader = new YamlStreamReader(sourceFile, _context.Collector);
8381
try
8482
{

src/Elastic.Documentation.Configuration/Builder/RedirectFile.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,19 @@ namespace Elastic.Documentation.Configuration.Builder;
1010

1111
public record RedirectFile
1212
{
13-
public Dictionary<string, LinkRedirect>? Redirects { get; set; }
14-
private IFileInfo Source { get; init; }
15-
private IDocumentationSetContext Context { get; init; }
13+
public Dictionary<string, LinkRedirect>? Redirects { get; }
14+
public IFileInfo Source { get; }
15+
private IDocumentationSetContext Context { get; }
1616

17-
public RedirectFile(IFileInfo source, IDocumentationSetContext context)
17+
public RedirectFile(IDocumentationSetContext context, IFileInfo? source = null)
1818
{
19-
Source = source;
19+
var docsetConfigurationPath = context.ConfigurationPath;
20+
var redirectFileName = docsetConfigurationPath.Name.StartsWith('_') ? "_redirects.yml" : "redirects.yml";
21+
var redirectFileInfo = docsetConfigurationPath.FileSystem.FileInfo.New(Path.Combine(docsetConfigurationPath.Directory!.FullName, redirectFileName));
22+
Source = source ?? redirectFileInfo;
2023
Context = context;
2124

22-
if (!source.Exists)
25+
if (!Source.Exists)
2326
return;
2427

2528
var reader = new YamlStreamReader(Source, Context.Collector);

src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
44

5+
using System.Globalization;
56
using System.IO.Abstractions;
7+
using static System.StringComparison;
68

79
namespace Elastic.Documentation.Extensions;
810

@@ -25,17 +27,68 @@ public static bool IsSubPathOf(this IFileInfo file, IDirectoryInfo parentDirecto
2527
var parent = file.Directory;
2628
return parent is not null && parent.IsSubPathOf(parentDirectory);
2729
}
30+
31+
/// Checks if <paramref name="file"/> has parent directory <paramref name="parentName"/>, defaults to OrdinalIgnoreCase comparison
32+
public static bool HasParent(this IFileInfo file, string parentName)
33+
{
34+
var parent = file.Directory;
35+
return parent is not null && parent.HasParent(parentName);
36+
}
2837
}
2938

3039
public static class IDirectoryInfoExtensions
3140
{
41+
private static bool? CaseSensitiveOsCheck;
42+
public static bool IsCaseSensitiveFileSystem
43+
{
44+
45+
get
46+
{
47+
// heuristic to determine if the OS is case-sensitive
48+
try
49+
{
50+
var tmp = Path.GetTempPath();
51+
if (CaseSensitiveOsCheck.HasValue)
52+
return CaseSensitiveOsCheck.Value;
53+
var culture = CultureInfo.CurrentCulture;
54+
CaseSensitiveOsCheck = !Directory.Exists(tmp.ToUpper(culture)) || !Directory.Exists(tmp.ToLower(culture));
55+
return CaseSensitiveOsCheck ?? false;
56+
57+
}
58+
catch
59+
{
60+
// fallback to case-insensitive unless it's linux
61+
CaseSensitiveOsCheck = Environment.OSVersion.Platform == PlatformID.Unix;
62+
return false;
63+
}
64+
}
65+
}
66+
67+
3268
/// Validates <paramref name="directory"/> is subdirectory of <paramref name="parentDirectory"/>
3369
public static bool IsSubPathOf(this IDirectoryInfo directory, IDirectoryInfo parentDirectory)
3470
{
71+
var cmp = IsCaseSensitiveFileSystem ? Ordinal : OrdinalIgnoreCase;
72+
var parent = directory;
73+
do
74+
{
75+
if (string.Equals(parent.FullName, parentDirectory.FullName, cmp))
76+
return true;
77+
parent = parent.Parent;
78+
} while (parent != null);
79+
80+
return false;
81+
}
82+
83+
/// Checks if <paramref name="directory"/> has parent directory <paramref name="parentName"/>, defaults to OrdinalIgnoreCase comparison
84+
public static bool HasParent(this IDirectoryInfo directory, string parentName, StringComparison comparison = OrdinalIgnoreCase)
85+
{
86+
if (string.Equals(directory.Name, parentName, comparison))
87+
return true;
3588
var parent = directory;
3689
do
3790
{
38-
if (parent.FullName == parentDirectory.FullName)
91+
if (string.Equals(parent.Name, parentName, comparison))
3992
return true;
4093
parent = parent.Parent;
4194
} while (parent != null);

src/tooling/docs-builder/Cli/DiffCommands.cs

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Elastic.Documentation;
1111
using Elastic.Documentation.Configuration;
1212
using Elastic.Documentation.Configuration.Builder;
13+
using Elastic.Documentation.Extensions;
1314
using Elastic.Documentation.Tooling.Diagnostics.Console;
1415
using Microsoft.Extensions.Logging;
1516

@@ -26,57 +27,79 @@ IConfigurationContext configurationContext
2627
/// <summary>
2728
/// Validates redirect updates in the current branch using the redirect file against changes reported by git.
2829
/// </summary>
29-
/// <param name="path">The baseline path to perform the check</param>
30+
/// <param name="path"> -p, Defaults to the`{pwd}/docs` folder</param>
3031
/// <param name="ctx"></param>
3132
[SuppressMessage("Usage", "CA2254:Template should be a static expression")]
3233
[Command("validate")]
33-
public async Task<int> ValidateRedirects([Argument] string? path = null, Cancel ctx = default)
34+
public async Task<int> ValidateRedirects(string? path = null, Cancel ctx = default)
3435
{
3536
var runningOnCi = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"));
36-
path ??= "docs";
3737

3838
await using var collector = new ConsoleDiagnosticsCollector(logFactory, githubActionsService).StartAsync(ctx);
3939

4040
var fs = new FileSystem();
41-
var root = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName);
4241

43-
var buildContext = new BuildContext(collector, fs, fs, configurationContext, ExportOptions.MetadataOnly, root.FullName, null);
44-
var sourceFile = buildContext.ConfigurationPath;
45-
var redirectFileName = sourceFile.Name.StartsWith('_') ? "_redirects.yml" : "redirects.yml";
46-
var redirectFileInfo = sourceFile.FileSystem.FileInfo.New(Path.Combine(sourceFile.Directory!.FullName, redirectFileName));
47-
if (!redirectFileInfo.Exists)
42+
var buildContext = new BuildContext(collector, fs, fs, configurationContext, ExportOptions.MetadataOnly, path, null);
43+
var redirectFile = new RedirectFile(buildContext);
44+
if (!redirectFile.Source.Exists)
4845
{
4946
await collector.StopAsync(ctx);
5047
return 0;
5148
}
5249

53-
var redirectFileParser = new RedirectFile(redirectFileInfo, buildContext);
54-
var redirects = redirectFileParser.Redirects;
50+
var redirects = redirectFile.Redirects;
5551

5652
if (redirects is null)
5753
{
58-
collector.EmitError(redirectFileInfo, "It was not possible to parse the redirects file.");
54+
collector.EmitError(redirectFile.Source, "It was not possible to parse the redirects file.");
5955
await collector.StopAsync(ctx);
6056
return collector.Errors;
6157
}
6258

63-
IRepositoryTracker tracker = runningOnCi ? new IntegrationGitRepositoryTracker(path) : new LocalGitRepositoryTracker(collector, root, path);
64-
var changed = tracker.GetChangedFiles().ToArray();
59+
var root = Paths.DetermineSourceDirectoryRoot(buildContext.DocumentationSourceDirectory);
60+
if (root is null)
61+
{
62+
collector.EmitError(redirectFile.Source, $"Unable to determine the root of the source directory {buildContext.DocumentationSourceDirectory}.");
63+
await collector.StopAsync(ctx);
64+
return collector.Errors;
65+
}
66+
var relativePath = Path.GetRelativePath(root.FullName, buildContext.DocumentationSourceDirectory.FullName);
67+
_log.LogInformation("Using relative path {RelativePath} for validating changes", relativePath);
68+
IRepositoryTracker tracker = runningOnCi ? new IntegrationGitRepositoryTracker(relativePath) : new LocalGitRepositoryTracker(collector, root, relativePath);
69+
var changed = tracker.GetChangedFiles()
70+
.Where(c =>
71+
{
72+
var fi = fs.FileInfo.New(c.FilePath);
73+
return fi.Extension is ".md" && !fi.HasParent("_snippets");
74+
})
75+
.ToArray();
6576

6677
if (changed.Length != 0)
6778
_log.LogInformation("Found {Count} changes to files related to documentation in the current branch.", changed.Length);
6879

69-
foreach (var notFound in changed.DistinctBy(c => c.FilePath).Where(c => c.ChangeType is GitChangeType.Deleted or GitChangeType.Renamed
70-
&& !redirects.ContainsKey(c is RenamedGitChange renamed ? renamed.OldFilePath : c.FilePath)))
80+
var missingRedirects = changed
81+
.Where(c =>
82+
c.ChangeType is GitChangeType.Deleted or GitChangeType.Renamed
83+
&& !redirects.ContainsKey(c is RenamedGitChange renamed ? renamed.OldFilePath : c.FilePath)
84+
)
85+
.ToArray();
86+
87+
if (missingRedirects.Length != 0)
88+
{
89+
var relativeRedirectFile = Path.GetRelativePath(root.FullName, redirectFile.Source.FullName);
90+
_log.LogInformation("Found {Count} changes that still require updates to: {RedirectFile}", missingRedirects.Length, relativeRedirectFile);
91+
}
92+
93+
foreach (var notFound in missingRedirects)
7194
{
7295
if (notFound is RenamedGitChange renamed)
7396
{
74-
collector.EmitError(redirectFileInfo.Name,
97+
collector.EmitError(redirectFile.Source,
7598
$"File '{renamed.OldFilePath}' was renamed to '{renamed.NewFilePath}' but it has no redirect configuration set.");
7699
}
77100
else if (notFound.ChangeType is GitChangeType.Deleted)
78101
{
79-
collector.EmitError(redirectFileInfo.Name,
102+
collector.EmitError(redirectFile.Source,
80103
$"File '{notFound.FilePath}' was deleted but it has no redirect targets. This will lead to broken links.");
81104
}
82105
}

src/tooling/docs-builder/Tracking/IRepositoryTracker.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@ public record RenamedGitChange(string OldFilePath, string NewFilePath, GitChange
1919

2020
public interface IRepositoryTracker
2121
{
22-
IEnumerable<GitChange> GetChangedFiles();
22+
IReadOnlyCollection<GitChange> GetChangedFiles();
2323
}

src/tooling/docs-builder/Tracking/IntegrationGitRepositoryTracker.cs

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,45 @@ namespace Documentation.Builder.Tracking;
66

77
public class IntegrationGitRepositoryTracker(string lookupPath) : IRepositoryTracker
88
{
9-
private string LookupPath { get; } = $"{lookupPath}/";
10-
public IEnumerable<GitChange> GetChangedFiles()
9+
private string LookupPath { get; } = $"{lookupPath.Trim(['/', '\\'])}/";
10+
public IReadOnlyCollection<GitChange> GetChangedFiles()
1111
{
12-
var deletedFiles = Environment.GetEnvironmentVariable("DELETED_FILES") ?? string.Empty;
13-
if (!string.IsNullOrEmpty(deletedFiles))
14-
{
15-
foreach (var file in deletedFiles.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(f => f.StartsWith(LookupPath)))
16-
yield return new GitChange(file, GitChangeType.Deleted);
17-
}
12+
return GetChanges().ToArray();
1813

19-
var addedFiles = Environment.GetEnvironmentVariable("ADDED_FILES");
20-
if (!string.IsNullOrEmpty(addedFiles))
14+
IEnumerable<GitChange> GetChanges()
2115
{
22-
foreach (var file in addedFiles.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(f => f.StartsWith(LookupPath)))
23-
yield return new GitChange(file, GitChangeType.Added);
24-
}
16+
var deletedFiles = Environment.GetEnvironmentVariable("DELETED_FILES") ?? string.Empty;
17+
if (!string.IsNullOrEmpty(deletedFiles))
18+
{
19+
foreach (var file in deletedFiles.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(f => f.StartsWith(LookupPath)))
20+
yield return new GitChange(file, GitChangeType.Deleted);
21+
}
2522

26-
var modifiedFiles = Environment.GetEnvironmentVariable("MODIFIED_FILES");
27-
if (!string.IsNullOrEmpty(modifiedFiles))
28-
{
29-
foreach (var file in modifiedFiles.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(f => f.StartsWith(LookupPath)))
30-
yield return new GitChange(file, GitChangeType.Modified);
31-
}
23+
var addedFiles = Environment.GetEnvironmentVariable("ADDED_FILES");
24+
if (!string.IsNullOrEmpty(addedFiles))
25+
{
26+
foreach (var file in addedFiles.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(f => f.StartsWith(LookupPath)))
27+
yield return new GitChange(file, GitChangeType.Added);
28+
}
3229

33-
var renamedFiles = Environment.GetEnvironmentVariable("RENAMED_FILES");
34-
if (!string.IsNullOrEmpty(renamedFiles))
35-
{
36-
foreach (var pair in renamedFiles.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(f => f.StartsWith(LookupPath)))
30+
var modifiedFiles = Environment.GetEnvironmentVariable("MODIFIED_FILES");
31+
if (!string.IsNullOrEmpty(modifiedFiles))
3732
{
38-
var parts = pair.Split(':');
39-
if (parts.Length == 2)
40-
yield return new RenamedGitChange(parts[0], parts[1], GitChangeType.Renamed);
33+
foreach (var file in modifiedFiles.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(f => f.StartsWith(LookupPath)))
34+
yield return new GitChange(file, GitChangeType.Modified);
4135
}
36+
37+
var renamedFiles = Environment.GetEnvironmentVariable("RENAMED_FILES");
38+
if (!string.IsNullOrEmpty(renamedFiles))
39+
{
40+
foreach (var pair in renamedFiles.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(f => f.StartsWith(LookupPath)))
41+
{
42+
var parts = pair.Split(':');
43+
if (parts.Length == 2)
44+
yield return new RenamedGitChange(parts[0], parts[1], GitChangeType.Renamed);
45+
}
46+
}
47+
4248
}
4349
}
4450
}

src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@ namespace Documentation.Builder.Tracking;
1010

1111
public class LocalGitRepositoryTracker(DiagnosticsCollector collector, IDirectoryInfo workingDirectory, string lookupPath) : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker
1212
{
13-
private string LookupPath { get; } = lookupPath;
13+
private string LookupPath { get; } = lookupPath.Trim('\\', '/');
1414

15-
public IEnumerable<GitChange> GetChangedFiles()
15+
public IReadOnlyCollection<GitChange> GetChangedFiles()
1616
{
1717
var defaultBranch = GetDefaultBranch();
1818
var commitChanges = CaptureMultiple("git", "diff", "--name-status", $"{defaultBranch}...HEAD", "--", $"./{LookupPath}");
1919
var localChanges = CaptureMultiple("git", "status", "--porcelain");
20-
ExecInSilent([], "git", "stash", "push", "--", $"./{LookupPath}");
21-
var localUnstagedChanges = CaptureMultiple("git", "stash", "show", "--name-status", "-u");
22-
ExecInSilent([], "git", "stash", "pop");
20+
var gitStashDocsFolder = Capture("git", "stash", "push", "--", $"./{LookupPath}");
21+
var localUnstagedChanges = Array.Empty<string>();
22+
if (!string.Equals(gitStashDocsFolder, "No local changes to save", StringComparison.Ordinal))
23+
{
24+
localUnstagedChanges = CaptureMultiple("git", "stash", "show", "--name-status", "-u");
25+
ExecInSilent([], "git", "stash", "pop");
26+
}
2327

2428
return [.. GetCommitChanges(commitChanges), .. GetLocalChanges(localChanges), .. GetCommitChanges(localUnstagedChanges)];
2529
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.IO.Abstractions.TestingHelpers;
6+
using Elastic.Documentation;
7+
using Elastic.Documentation.Configuration;
8+
using Elastic.Documentation.Configuration.Versions;
9+
using Elastic.Documentation.Diagnostics;
10+
using Elastic.Documentation.Extensions;
11+
using Elastic.Markdown.IO;
12+
using FluentAssertions;
13+
14+
namespace Elastic.Markdown.Tests;
15+
16+
public class FileSystemExtensionsTest(ITestOutputHelper output)
17+
{
18+
[Fact]
19+
public void IsSubPathOfTests()
20+
{
21+
var fs = new MockFileSystem();
22+
23+
fs.DirectoryInfo.New("/a/b/c/d").IsSubPathOf(fs.DirectoryInfo.New("/a/b/c/d/e/f")).Should().BeFalse();
24+
fs.DirectoryInfo.New("/a/b/c/d/e/f").IsSubPathOf(fs.DirectoryInfo.New("/a/b/c/d")).Should().BeTrue();
25+
26+
var caseSensitive = IDirectoryInfoExtensions.IsCaseSensitiveFileSystem;
27+
28+
if (caseSensitive)
29+
{
30+
fs.DirectoryInfo.New("/a/b/C/d/e/f").IsSubPathOf(fs.DirectoryInfo.New("/a/b/c/d")).Should().BeFalse();
31+
fs.DirectoryInfo.New("/a/b/c/d/e/f").IsSubPathOf(fs.DirectoryInfo.New("/a/b/C/d")).Should().BeFalse();
32+
}
33+
else
34+
{
35+
fs.DirectoryInfo.New("/a/b/C/d/e/f").IsSubPathOf(fs.DirectoryInfo.New("/a/b/c/d")).Should().BeTrue();
36+
fs.DirectoryInfo.New("/a/b/c/d/e/f").IsSubPathOf(fs.DirectoryInfo.New("/a/b/C/d")).Should().BeTrue();
37+
}
38+
}
39+
40+
[Fact]
41+
public void HasParentTests()
42+
{
43+
var fs = new MockFileSystem();
44+
45+
fs.DirectoryInfo.New("/a/b/c/d").HasParent("c").Should().BeTrue();
46+
fs.DirectoryInfo.New("/a/b/c/d/e").HasParent("e").Should().BeTrue();
47+
48+
fs.DirectoryInfo.New("/a/b/C/d").HasParent("c", StringComparison.Ordinal).Should().BeFalse();
49+
var caseSensitive = IDirectoryInfoExtensions.IsCaseSensitiveFileSystem;
50+
51+
// HasParent is always case-insensitive by default
52+
if (caseSensitive)
53+
fs.DirectoryInfo.New("/a/b/C/d").HasParent("c").Should().BeTrue();
54+
else
55+
fs.DirectoryInfo.New("/a/b/C/d").HasParent("c").Should().BeTrue();
56+
}
57+
}

0 commit comments

Comments
 (0)