Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 48 additions & 5 deletions GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Workspace;

Expand All @@ -11,34 +14,74 @@ public static class WorkspaceConfigurationExtensions
{
/// <summary>
/// Gets all unique files from all manifests, deduplicated by relative path.
/// When multiple manifests contain the same file path, returns the first occurrence.
/// When multiple manifests contain the same file path, returns the file from the highest priority manifest.
/// </summary>
/// <param name="configuration">The workspace configuration to get files from.</param>
/// <returns>An enumerable of unique manifest files.</returns>
public static IEnumerable<ManifestFile> GetAllUniqueFiles(
this WorkspaceConfiguration configuration)
{
if (configuration?.Manifests is null || configuration.Manifests.Count == 0)
{
return [];
}

return configuration.Manifests
.SelectMany(m => (m.Files ?? []).Select(f => new { File = f, Manifest = m }))
.SelectMany((m, index) => (m.Files ?? []).Select(f => new { File = f, Manifest = m, ManifestIndex = index }))
.GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Test-only comment - needs real review text.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: GroupBy(..., StringComparer.OrdinalIgnoreCase) deduplicates by case-insensitive path. On Linux/macOS, file systems are case-sensitive, so distinct files such as Config.ini and config.ini would be wrongly merged and one would be dropped from every workspace strategy that consumes this list. The grouping should follow the case sensitivity of the host file system (e.g. StringComparer.Ordinal on Unix, OrdinalIgnoreCase on Windows).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: (Replaces earlier placeholder.) GroupBy(..., StringComparer.OrdinalIgnoreCase) deduplicates by case-insensitive path. On Linux/macOS, file systems are case-sensitive, so distinct files such as Config.ini and config.ini would be wrongly merged and one would be dropped from every workspace strategy that consumes this list. The grouping should follow the case sensitivity of the host file system (e.g. StringComparer.Ordinal on Unix, OrdinalIgnoreCase on Windows).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

.Select(g => g.OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType))
.ThenByDescending(x => x.ManifestIndex)
Comment on lines +30 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. getalluniquefiles exposes deferred sequence 📘 Rule violation ⚙ Maintainability

The modified public collection API still returns IEnumerable<ManifestFile> backed by a deferred
LINQ query. This permits repeated execution and violates the requirement to expose a read-only
collection that is eagerly materialized.
Agent Prompt
## Issue description
`GetAllUniqueFiles` exposes a deferred `IEnumerable<ManifestFile>` rather than an eagerly materialized read-only collection.

## Issue Context
The method returns a finite deduplicated collection, so callers should receive a stable `IReadOnlyList<ManifestFile>` or `IReadOnlyCollection<ManifestFile>` without re-running its LINQ pipeline.

## Fix Focus Areas
- GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs[18-35]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

.First().File);
}

/// <summary>
/// Gets all unique files intended for the workspace from all manifests, deduplicated by relative path.
/// Only includes files where <see cref="ManifestFile.InstallTarget"/> is <see cref="GenHub.Core.Models.Enums.ContentInstallTarget.Workspace"/>.
/// Only includes files where <see cref="ManifestFile.InstallTarget"/> is <see cref="ContentInstallTarget.Workspace"/>.
/// Higher-priority manifests (such as mods and patches) take precedence over lower-priority manifests (such as base game installations).
/// </summary>
/// <param name="configuration">The workspace configuration to get files from.</param>
/// <returns>An enumerable of unique workspace-specific manifest files.</returns>
public static IEnumerable<ManifestFile> GetWorkspaceUniqueFiles(
this WorkspaceConfiguration configuration)
{
if (configuration?.Manifests is null || configuration.Manifests.Count == 0)
{
return [];
}

return configuration.Manifests
.SelectMany(m => (m.Files ?? []).Select(f => new { File = f, Manifest = m }))
.Where(x => x.File.InstallTarget == GenHub.Core.Models.Enums.ContentInstallTarget.Workspace)
.SelectMany((m, index) => (m.Files ?? []).Select(f => new { File = f, Manifest = m, ManifestIndex = index }))
.Where(x => x.File.InstallTarget == ContentInstallTarget.Workspace)
.GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Same case-insensitive grouping concern as above in GetWorkspaceUniqueFiles. On Linux/macOS this drops legitimately distinct paths that only differ in letter case. The same fix (host-aware comparer) should be applied here.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

.Select(g => g.OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType))
Comment on lines +53 to 56

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. getworkspaceuniquefiles remains deferred 📘 Rule violation ⚙ Maintainability

The modified public workspace-file lookup returns a deferred LINQ pipeline through
IEnumerable<ManifestFile>. Its finite result should be materialized and exposed as a read-only
collection.
Agent Prompt
## Issue description
`GetWorkspaceUniqueFiles` returns a deferred `IEnumerable<ManifestFile>` instead of an eagerly materialized read-only collection.

## Issue Context
The lookup computes a finite, deduplicated set whose query should execute inside the extension method and produce a stable collection for callers.

## Fix Focus Areas
- GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs[37-59]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

.ThenByDescending(x => x.ManifestIndex)
.First().File);
}

/// <summary>
/// Gets all unique workspace files paired with their owning manifest, deduplicated by relative path
/// and resolved by content type priority (higher-priority content types override lower-priority ones).
/// </summary>
/// <param name="configuration">The workspace configuration to get prioritized files from.</param>
/// <returns>A read-only list of prioritized file and manifest pairs.</returns>
public static IReadOnlyList<(ManifestFile File, ContentManifest Manifest)> GetPrioritizedWorkspaceFiles(
this WorkspaceConfiguration configuration)
{
if (configuration?.Manifests is null || configuration.Manifests.Count == 0)
{
return [];
}

return configuration.Manifests
.SelectMany((manifest, index) => (manifest.Files ?? Enumerable.Empty<ManifestFile>())
.Where(f => f.InstallTarget == ContentInstallTarget.Workspace)
.Select(file => new { File = file, Manifest = manifest, ManifestIndex = index }))
.GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

6. Unix case-distinct files collapse 🐞 Bug ≡ Correctness

The new selector always groups paths with OrdinalIgnoreCase, so Hybrid now treats distinct Unix
destinations such as Data/foo and Data/Foo as one conflict and materializes only one file. The
repository's path policy explicitly uses case-sensitive comparison outside Windows, making this a
cross-platform data omission.
Agent Prompt
## Issue description
Workspace conflict grouping unconditionally folds path case, which drops valid case-distinct files on case-sensitive filesystems.

## Issue Context
Use the repository's platform-aware filesystem comparer consistently in all workspace-file grouping helpers, and add a Unix regression test with two case-distinct relative paths.

## Fix Focus Areas
- GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs[31-32]
- GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs[55-56]
- GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs[79-83]
- GenHub/GenHub.Core/Helpers/PathHelper.cs[34-41]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

.Select(g => g
.OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: When two manifests share the same ContentType priority, the tie-breaker is ThenByDescending(x => x.ManifestIndex) which silently picks the LAST manifest. This is deterministic only when the manifests collection order is stable, but it is also undocumented: a higher-priority Mod placed after a lower-priority Mod would still be lost. Either document the rule or pick by an explicit source priority (manifest id/version) so callers can reason about the outcome.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

.ThenByDescending(x => x.ManifestIndex)
.First())
Comment on lines +79 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

5. Missing winner drops fallback 🐞 Bug ≡ Correctness

GetPrioritizedWorkspaceFiles discards lower-priority candidates before source availability is
checked, so a missing local mod/patch winner prevents FullCopy and Hybrid from materializing an
available base file at the same path. Both strategies treat the missing winner as non-fatal and can
mark the workspace prepared, while validation uses the reduced successful-file count and therefore
does not detect the omitted required destination.
Agent Prompt
## Issue description
Priority deduplication removes lower-priority candidates before the selected source is validated. If a local mod/patch winner is unavailable, FullCopy and Hybrid can silently omit the destination and report successful preparation even when a valid lower-priority base candidate exists.

## Issue Context
Preserve strict priority when the preferred override is available, but resolve candidates with manifest context and select the highest-priority candidate whose source can be materialized, or explicitly fail preparation when no acceptable source is available. `ValidateSourceFile` currently returns `false` rather than failing for missing local files, after which the strategies can mark the workspace prepared; validation must not rely only on the reduced count of successfully processed files or leave a manifest destination unaccounted for.

## Fix Focus Areas
- GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs[75-85]
- GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs[139-186]
- GenHub/GenHub/Features/Workspace/Strategies/HybridCopySymlinkStrategy.cs[155-225]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

.Select(x => (x.File, x.Manifest))
.ToList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,14 @@ public async Task PrepareWorkspace_FullCopyStrategy_CopiesGameInstallationAndCli
[Fact]
public async Task PrepareWorkspace_SymlinkStrategy_LinksGameInstallationAndClientFilesAsync()
{
// Skip on systems that don't support symlinks
// Skip on Windows if not running with administrator privileges
bool isWindows = OperatingSystem.IsWindows();
bool isAdmin = isWindows &&
new System.Security.Principal.WindowsPrincipal(
System.Security.Principal.WindowsIdentity.GetCurrent())
.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);

if (!isWindows || !isAdmin)
if (isWindows && !isAdmin)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: if (isWindows && !isAdmin) return; silently reports as pass when skipped. Use Skip = "Requires admin on Windows" / [SkippableFact] so skipped scenarios are surfaced in CI rather than masked as green.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

{
return;
}
Expand Down Expand Up @@ -228,7 +228,7 @@ public async Task PrepareWorkspace_MixedContentTypes_HandlesGameInstallationAndG
System.Security.Principal.WindowsIdentity.GetCurrent())
.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);

if (!isWindows || !isAdmin)
if (isWindows && !isAdmin)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: Same runtime skip concern — prefer [SkippableFact(Skip = "Requires admin on Windows")] to make the skipped run visible in test output.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

{
return;
}
Expand Down Expand Up @@ -336,7 +336,7 @@ public async Task PrepareWorkspace_AllStrategies_HandleGameInstallationFilesAsyn

// Skip symlink strategies when not admin on Windows
if ((strategy == WorkspaceStrategy.SymlinkOnly || strategy == WorkspaceStrategy.HybridCopySymlink) &&
(!isWindows || !isAdmin))
isWindows && !isAdmin)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: Same skip-pattern observation as above for the parametrized strategy test.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

{
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ public async Task EndToEndWorkspaceCreation_AllStrategiesAsync(WorkspaceStrategy

if ((strategy == WorkspaceStrategy.SymlinkOnly ||
strategy == WorkspaceStrategy.HybridCopySymlink) &&
(!isWindows || !isAdmin))
isWindows && !isAdmin)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: Same observation as WorkspaceCasIntegrationTests: the silent return makes the test look like it passed on skipped platforms. Recommend [SkippableFact(Skip = ...)] so test reporters reflect that the scenario was not exercised.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

{
return;
}
Expand Down
Loading
Loading