From 707753574a9aff6ea976b4736bafc5c43ba14326 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 19 Sep 2025 18:44:28 +0000
Subject: [PATCH 1/5] Initial plan
From edc8e275287576126cdfb5742a794085128371bd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 19 Sep 2025 18:54:40 +0000
Subject: [PATCH 2/5] Implement slnx command with project type detection and
folder organization
Co-authored-by: dlosch <318550+dlosch@users.noreply.github.com>
---
bld.slnx | 6 +-
bld/Commands/RootCommand.cs | 1 +
bld/Commands/SlnxCommand.cs | 364 ++++++++++++++++++++++++++++
bld/Infrastructure/ProjConstants.cs | 6 +
bld/Models/ProjectModels.cs | 101 ++++++++
5 files changed, 476 insertions(+), 2 deletions(-)
create mode 100644 bld/Commands/SlnxCommand.cs
diff --git a/bld.slnx b/bld.slnx
index b0a88e5..8c0defa 100644
--- a/bld.slnx
+++ b/bld.slnx
@@ -4,5 +4,7 @@
-
-
+
+
+
+
\ No newline at end of file
diff --git a/bld/Commands/RootCommand.cs b/bld/Commands/RootCommand.cs
index 91ed31a..5cc8408 100644
--- a/bld/Commands/RootCommand.cs
+++ b/bld/Commands/RootCommand.cs
@@ -11,5 +11,6 @@ public RootCommand() : base("bld") {
Add(new CleanCommand(console));
Add(new StatsCommand(console));
+ Add(new SlnxCommand(console));
}
}
diff --git a/bld/Commands/SlnxCommand.cs b/bld/Commands/SlnxCommand.cs
new file mode 100644
index 0000000..1fe810e
--- /dev/null
+++ b/bld/Commands/SlnxCommand.cs
@@ -0,0 +1,364 @@
+using bld.Infrastructure;
+using bld.Models;
+using bld.Services;
+using System.CommandLine;
+using System.CommandLine.Parsing;
+using System.Text;
+using System.Xml.Linq;
+
+namespace bld.Commands;
+
+internal sealed class SlnxCommand : BaseCommand
+{
+ private readonly Option _outputOption = new Option("--output", "-o")
+ {
+ Description = "Output slnx file name. Defaults to directory name with .slnx extension.",
+ DefaultValueFactory = _ => null
+ };
+
+ private readonly Option _updateOption = new Option("--update", "-u")
+ {
+ Description = "Update existing slnx file if it exists.",
+ DefaultValueFactory = _ => true
+ };
+
+ public SlnxCommand(IConsoleOutput console) : base("slnx", "Create or update a .slnx file with all projects organized by type.", console)
+ {
+ Add(_rootOption);
+ Add(_depthOption);
+ Add(_outputOption);
+ Add(_updateOption);
+ Add(_logLevelOption);
+ Add(_vsToolsPath);
+ Add(_noResolveVsToolsPath);
+ Add(_rootArgument);
+ }
+
+ protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
+ {
+ var options = new CleaningOptions
+ {
+ Delete = false,
+ CleanOnlyNonCurrentTfms = false,
+ CleanObjDirectory = false,
+ CleanNupkgFiles = false,
+ LogLevel = parseResult.GetValue(_logLevelOption),
+ Depth = parseResult.GetValue(_depthOption),
+ VSToolsPath = parseResult.GetValue(_vsToolsPath),
+ NoResolveVSToolsPath = parseResult.GetValue(_noResolveVsToolsPath),
+ };
+
+ if (!options.NoResolveVSToolsPath && string.IsNullOrEmpty(options.VSToolsPath))
+ {
+ options.VSToolsPath = TryResolveVSToolsPath(out var vsRoot);
+ options.VSRootPath = vsRoot;
+ }
+
+ base.Console = new SpectreConsoleOutput(options.LogLevel);
+
+ var rootPath = parseResult.GetValue(_rootArgument) ?? parseResult.GetValue(_rootOption);
+ if (string.IsNullOrWhiteSpace(rootPath))
+ {
+ rootPath = Environment.CurrentDirectory;
+ }
+
+ var outputFile = parseResult.GetValue(_outputOption);
+ var updateExisting = parseResult.GetValue(_updateOption);
+
+ return await CreateSlnxFileAsync(rootPath, outputFile, updateExisting, options);
+ }
+
+ private async Task CreateSlnxFileAsync(string rootPath, string? outputFile, bool updateExisting, CleaningOptions options)
+ {
+ try
+ {
+ // Determine output file path
+ var rootDir = Path.GetFullPath(rootPath);
+ if (string.IsNullOrEmpty(outputFile))
+ {
+ var dirName = Path.GetFileName(rootDir);
+ outputFile = Path.Combine(rootDir, $"{dirName}.slnx");
+ }
+ else if (!Path.IsPathFullyQualified(outputFile))
+ {
+ outputFile = Path.Combine(rootDir, outputFile);
+ }
+
+ Console.WriteInfo($"Creating/updating slnx file: {outputFile}");
+ Console.WriteInfo($"Scanning for projects in: {rootDir}");
+
+ // Discover all project files
+ var projectFiles = await DiscoverProjectFilesAsync(rootDir, options);
+
+ if (!projectFiles.Any())
+ {
+ Console.WriteWarning("No project files found.");
+ return 0;
+ }
+
+ Console.WriteInfo($"Found {projectFiles.Count} project files");
+
+ // Parse projects and categorize them
+ var projectInfos = await ParseProjectsAsync(projectFiles, options);
+ var categorizedProjects = CategorizeProjects(projectInfos);
+
+ // Generate slnx content
+ var slnxContent = GenerateSlnxContent(categorizedProjects);
+
+ // Check if file exists and whether to update
+ if (File.Exists(outputFile) && !updateExisting)
+ {
+ Console.WriteWarning($"File {outputFile} already exists. Use --update to overwrite.");
+ return 1;
+ }
+
+ // Write the file
+ await File.WriteAllTextAsync(outputFile, slnxContent);
+ Console.WriteInfo($"Successfully created {outputFile}");
+
+ // Display summary
+ DisplaySummary(categorizedProjects);
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteError($"Error creating slnx file: {ex.Message}");
+ return 1;
+ }
+ }
+
+ private async Task> DiscoverProjectFilesAsync(string rootPath, CleaningOptions options)
+ {
+ var projectFiles = new List();
+ var extensions = new[] { "*.csproj", "*.fsproj", "*.vbproj" };
+
+ foreach (var extension in extensions)
+ {
+ var files = Directory.EnumerateFiles(rootPath, extension, new EnumerationOptions
+ {
+ IgnoreInaccessible = true,
+ MatchCasing = MatchCasing.CaseInsensitive,
+ MatchType = MatchType.Win32,
+ MaxRecursionDepth = options.Depth,
+ RecurseSubdirectories = true,
+ ReturnSpecialDirectories = false
+ });
+
+ projectFiles.AddRange(files);
+ }
+
+ return projectFiles;
+ }
+
+ private async Task> ParseProjectsAsync(List projectFiles, CleaningOptions options)
+ {
+ var projectInfos = new List();
+ var errorSink = new ErrorSink(Console);
+ var parser = new ProjParser(Console, errorSink, options);
+
+ foreach (var projectFile in projectFiles)
+ {
+ try
+ {
+ // Try MSBuild parsing first
+ var proj = new Proj(projectFile, null);
+ var projCfg = new ProjCfg(proj, "Debug"); // Use Debug configuration as default
+ var projectInfo = parser.LoadProject(projCfg, ProjConstants.PropertyNames);
+
+ if (projectInfo != null)
+ {
+ projectInfos.Add(projectInfo);
+ Console.WriteVerbose($"Parsed project via MSBuild: {projectInfo.ProjectName ?? Path.GetFileNameWithoutExtension(projectFile)}");
+ }
+ else
+ {
+ // Fallback to simple parsing
+ var fallbackInfo = await ParseProjectSimpleAsync(projectFile);
+ if (fallbackInfo != null)
+ {
+ projectInfos.Add(fallbackInfo);
+ Console.WriteVerbose($"Parsed project via fallback: {fallbackInfo.ProjectName ?? Path.GetFileNameWithoutExtension(projectFile)}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteWarning($"MSBuild parsing failed for {projectFile}: {ex.Message}");
+
+ // Fallback to simple parsing
+ try
+ {
+ var fallbackInfo = await ParseProjectSimpleAsync(projectFile);
+ if (fallbackInfo != null)
+ {
+ projectInfos.Add(fallbackInfo);
+ Console.WriteVerbose($"Parsed project via fallback: {fallbackInfo.ProjectName ?? Path.GetFileNameWithoutExtension(projectFile)}");
+ }
+ }
+ catch (Exception fallbackEx)
+ {
+ Console.WriteWarning($"Both MSBuild and fallback parsing failed for {projectFile}: {fallbackEx.Message}");
+ }
+ }
+ }
+
+ return projectInfos;
+ }
+
+ private async Task ParseProjectSimpleAsync(string projectPath)
+ {
+ var content = await File.ReadAllTextAsync(projectPath);
+ var projectName = Path.GetFileNameWithoutExtension(projectPath);
+
+ // Extract basic properties from XML
+ var properties = new Dictionary();
+
+ // Simple regex patterns to extract key properties
+ var patterns = new Dictionary
+ {
+ ["OutputType"] = @"([^<]+)",
+ ["Sdk"] = @"([^<]+)",
+ ["TargetFrameworks"] = @"([^<]+)",
+ ["UseWPF"] = @"([^<]+)",
+ ["UseWindowsForms"] = @"([^<]+)",
+ ["IsPackable"] = @"([^<]+)",
+ ["AssemblyName"] = @"([^<]+)",
+ ["ProjectName"] = @"([^<]+)",
+ };
+
+ foreach (var pattern in patterns)
+ {
+ var match = System.Text.RegularExpressions.Regex.Match(content, pattern.Value, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+ if (match.Success)
+ {
+ properties[pattern.Key] = match.Groups[1].Value.Trim();
+ }
+ }
+
+ // Set defaults
+ if (!properties.ContainsKey("ProjectName"))
+ properties["ProjectName"] = projectName;
+
+ if (!properties.ContainsKey("AssemblyName"))
+ properties["AssemblyName"] = projectName;
+
+ var targetFramework = properties.TryGetValue("TargetFramework", out var tf) ? tf : "";
+ var targetFrameworks = properties.TryGetValue("TargetFrameworks", out var tfs) ?
+ tfs.Split(';', StringSplitOptions.RemoveEmptyEntries).ToList() :
+ (string.IsNullOrEmpty(targetFramework) ? new List() : new List { targetFramework });
+
+ return new ProjectInfo
+ {
+ ProjectPath = projectPath,
+ ProjectName = properties.GetValueOrDefault("ProjectName", projectName),
+ AssemblyName = properties.GetValueOrDefault("AssemblyName", projectName),
+ TargetFramework = targetFramework,
+ TargetFrameworks = targetFrameworks,
+ Configuration = "Debug",
+ Properties = properties
+ };
+ }
+
+ private Dictionary> CategorizeProjects(List projects)
+ {
+ var categorized = new Dictionary>();
+
+ foreach (var project in projects)
+ {
+ var type = project.SlnxProjectType;
+ if (!categorized.ContainsKey(type))
+ {
+ categorized[type] = new List();
+ }
+ categorized[type].Add(project);
+ }
+
+ return categorized;
+ }
+
+ private string GenerateSlnxContent(Dictionary> categorizedProjects)
+ {
+ var doc = new XDocument(
+ new XDeclaration("1.0", "utf-8", null),
+ new XElement("Solution",
+ new XElement("Configurations",
+ new XElement("Platform", new XAttribute("Name", "Any CPU")),
+ new XElement("Platform", new XAttribute("Name", "x64")),
+ new XElement("Platform", new XAttribute("Name", "x86"))
+ )
+ )
+ );
+
+ var solutionElement = doc.Root!;
+
+ // Add projects organized by folders
+ foreach (var category in categorizedProjects.OrderBy(kvp => kvp.Key.ToString()))
+ {
+ var folderName = GetFolderName(category.Key);
+
+ if (category.Value.Count == 1 && category.Key == SlnxProjectType.Unknown)
+ {
+ // Don't create a folder for single unknown projects
+ var project = category.Value.First();
+ solutionElement.Add(new XElement("Project", new XAttribute("Path", GetRelativeProjectPath(project.ProjectPath))));
+ }
+ else
+ {
+ var folderElement = new XElement("Folder", new XAttribute("Name", folderName));
+
+ foreach (var project in category.Value.OrderBy(p => p.ProjectName ?? Path.GetFileNameWithoutExtension(p.ProjectPath)))
+ {
+ folderElement.Add(new XElement("Project", new XAttribute("Path", GetRelativeProjectPath(project.ProjectPath))));
+ }
+
+ solutionElement.Add(folderElement);
+ }
+ }
+
+ return doc.ToString();
+ }
+
+ private string GetFolderName(SlnxProjectType type)
+ {
+ return type switch
+ {
+ SlnxProjectType.Web => "Web",
+ SlnxProjectType.Console => "Console",
+ SlnxProjectType.Library => "Libraries",
+ SlnxProjectType.NuGet => "NuGet Packages",
+ SlnxProjectType.Tests => "Tests",
+ SlnxProjectType.WPF => "WPF Applications",
+ SlnxProjectType.WinForms => "WinForms Applications",
+ SlnxProjectType.Blazor => "Blazor Applications",
+ SlnxProjectType.Worker => "Worker Services",
+ SlnxProjectType.Function => "Azure Functions",
+ _ => "Other"
+ };
+ }
+
+ private string GetRelativeProjectPath(string projectPath)
+ {
+ var currentDir = Environment.CurrentDirectory;
+ var relativePath = Path.GetRelativePath(currentDir, projectPath);
+ return relativePath.Replace('\\', '/'); // Use forward slashes for consistency
+ }
+
+ private void DisplaySummary(Dictionary> categorizedProjects)
+ {
+ Console.WriteInfo("\nProject Summary:");
+ foreach (var category in categorizedProjects.OrderBy(kvp => kvp.Key.ToString()))
+ {
+ var folderName = GetFolderName(category.Key);
+ var count = category.Value.Count;
+ Console.WriteInfo($" {folderName}: {count} project{(count == 1 ? "" : "s")}");
+
+ foreach (var project in category.Value.OrderBy(p => p.ProjectName ?? Path.GetFileNameWithoutExtension(p.ProjectPath)))
+ {
+ var name = project.ProjectName ?? Path.GetFileNameWithoutExtension(project.ProjectPath);
+ Console.WriteVerbose($" - {name}");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/bld/Infrastructure/ProjConstants.cs b/bld/Infrastructure/ProjConstants.cs
index aaa1c7f..a403273 100644
--- a/bld/Infrastructure/ProjConstants.cs
+++ b/bld/Infrastructure/ProjConstants.cs
@@ -21,6 +21,12 @@ static class ProjConstants {
"PackageOutputPath",
"PackageId",
"AssemblyName",
+
+ // Additional properties for slnx project type detection
+ "OutputType",
+ "Sdk",
+ "UseWPF",
+ "UseWindowsForms",
// ContainerBaseImage
// ContainerFamily
diff --git a/bld/Models/ProjectModels.cs b/bld/Models/ProjectModels.cs
index 388c5b0..4490302 100644
--- a/bld/Models/ProjectModels.cs
+++ b/bld/Models/ProjectModels.cs
@@ -1,5 +1,23 @@
namespace bld.Models;
+///
+/// Enhanced project types for solution organization
+///
+public enum SlnxProjectType
+{
+ Unknown,
+ Web,
+ Console,
+ Library,
+ NuGet,
+ Tests,
+ WPF,
+ WinForms,
+ Blazor,
+ Worker,
+ Function
+}
+
///
/// Information about a project
///
@@ -20,6 +38,89 @@ internal record ProjectInfo {
public bool HasDockerProperties { get; init; }
public string? OutDir { get; internal set; }
public string? BaseOutputPath { get; internal set; }
+
+ ///
+ /// Determines the SlnxProjectType based on project properties
+ ///
+ public SlnxProjectType SlnxProjectType => DetermineProjectType();
+
+ private SlnxProjectType DetermineProjectType()
+ {
+ var outputType = Properties.TryGetValue("OutputType", out var ot) ? ot?.ToLowerInvariant() : "";
+ var sdk = Properties.TryGetValue("Sdk", out var s) ? s : "";
+ var usingMicrosoftNETSdk = Properties.TryGetValue("UsingMicrosoftNETSdk", out var ms) ? ms : "";
+ var isPackable = Properties.TryGetValue("IsPackable", out var pack) && pack?.ToLowerInvariant() == "true";
+ var useWpf = Properties.TryGetValue("UseWPF", out var wpf) && wpf?.ToLowerInvariant() == "true";
+ var useWinForms = Properties.TryGetValue("UseWindowsForms", out var wf) && wf?.ToLowerInvariant() == "true";
+ var targetFramework = TargetFramework ?? "";
+
+ // Check for test projects first (most specific)
+ if (IsTestProject()) return SlnxProjectType.Tests;
+
+ // Check SDK types (most specific)
+ if (sdk?.Contains("Microsoft.NET.Sdk.Web") == true || sdk?.Contains("Web") == true)
+ {
+ return IsBlazorProject() ? SlnxProjectType.Blazor : SlnxProjectType.Web;
+ }
+
+ if (sdk?.Contains("Microsoft.NET.Sdk.Worker") == true) return SlnxProjectType.Worker;
+ if (sdk?.Contains("Microsoft.Azure.Functions") == true) return SlnxProjectType.Function;
+
+ // Check UI frameworks
+ if (useWpf) return SlnxProjectType.WPF;
+ if (useWinForms) return SlnxProjectType.WinForms;
+
+ // Check for packable projects (NuGet packages) - but only if it's also a library
+ if (isPackable && (outputType == "library" || string.IsNullOrEmpty(outputType)))
+ return SlnxProjectType.NuGet;
+
+ // Check output type
+ switch (outputType)
+ {
+ case "exe":
+ case "winexe":
+ return SlnxProjectType.Console;
+ case "library":
+ case "": // Default for SDK-style projects without explicit OutputType is library
+ return SlnxProjectType.Library;
+ default:
+ return SlnxProjectType.Unknown;
+ }
+ }
+
+ private bool IsTestProject()
+ {
+ var projectName = ProjectName?.ToLowerInvariant() ?? "";
+ var assemblyName = AssemblyName?.ToLowerInvariant() ?? "";
+ var projectPath = ProjectPath?.ToLowerInvariant() ?? "";
+
+ // Check for explicit test project property
+ if (Properties.TryGetValue("IsTestProject", out var isTest) && isTest?.ToLowerInvariant() == "true")
+ return true;
+
+ // Check project name patterns - be more specific
+ if (projectName.EndsWith(".tests") || projectName.EndsWith(".test") ||
+ assemblyName.EndsWith(".tests") || assemblyName.EndsWith(".test"))
+ {
+ return true;
+ }
+
+ // Check path patterns - be more specific
+ if (projectPath.Contains("/test/") || projectPath.Contains("\\test\\") ||
+ projectPath.Contains("/tests/") || projectPath.Contains("\\tests\\"))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool IsBlazorProject()
+ {
+ // Simple check - would need more sophisticated detection in real implementation
+ var projectName = ProjectName?.ToLowerInvariant() ?? "";
+ return projectName.Contains("blazor");
+ }
}
///
From 9b7bcd43e7eb282854f7f7e1ee214d58ebb4a62b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 19 Sep 2025 19:50:04 +0000
Subject: [PATCH 3/5] Fix slnx folder path format to comply with Visual Studio
requirements
Co-authored-by: dlosch <318550+dlosch@users.noreply.github.com>
---
bld.slnx | 2 +-
bld/Commands/SlnxCommand.cs | 3 ++-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/bld.slnx b/bld.slnx
index 8c0defa..a8636af 100644
--- a/bld.slnx
+++ b/bld.slnx
@@ -4,7 +4,7 @@
-
+
\ No newline at end of file
diff --git a/bld/Commands/SlnxCommand.cs b/bld/Commands/SlnxCommand.cs
index 1fe810e..1bd0355 100644
--- a/bld/Commands/SlnxCommand.cs
+++ b/bld/Commands/SlnxCommand.cs
@@ -306,7 +306,8 @@ private string GenerateSlnxContent(Dictionary
}
else
{
- var folderElement = new XElement("Folder", new XAttribute("Name", folderName));
+ var folderPath = $"/{folderName}/";
+ var folderElement = new XElement("Folder", new XAttribute("Name", folderPath));
foreach (var project in category.Value.OrderBy(p => p.ProjectName ?? Path.GetFileNameWithoutExtension(p.ProjectPath)))
{
From 31147007a7c82b2e05208cbf20172fce5af0b689 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 19 Sep 2025 20:13:55 +0000
Subject: [PATCH 4/5] Add support for multiple root directories in slnx command
Co-authored-by: dlosch <318550+dlosch@users.noreply.github.com>
---
bld/Commands/SlnxCommand.cs | 101 ++++++++++++++++++++++++++----------
1 file changed, 74 insertions(+), 27 deletions(-)
diff --git a/bld/Commands/SlnxCommand.cs b/bld/Commands/SlnxCommand.cs
index 1bd0355..7694f86 100644
--- a/bld/Commands/SlnxCommand.cs
+++ b/bld/Commands/SlnxCommand.cs
@@ -22,6 +22,24 @@ internal sealed class SlnxCommand : BaseCommand
DefaultValueFactory = _ => true
};
+ private readonly Argument _rootsArgument = new Argument("roots")
+ {
+ Arity = ArgumentArity.ZeroOrMore,
+ Description = "Root directories to scan for projects. Defaults to current directory if none specified.",
+ Validators = {
+ result => {
+ var paths = result.GetValueOrDefault() ?? Array.Empty();
+ foreach (var path in paths)
+ {
+ if (!string.IsNullOrEmpty(path) && !Directory.Exists(path) && !File.Exists(path))
+ {
+ result.AddError($"{path} does not exist.");
+ }
+ }
+ }
+ }
+ };
+
public SlnxCommand(IConsoleOutput console) : base("slnx", "Create or update a .slnx file with all projects organized by type.", console)
{
Add(_rootOption);
@@ -31,7 +49,7 @@ public SlnxCommand(IConsoleOutput console) : base("slnx", "Create or update a .s
Add(_logLevelOption);
Add(_vsToolsPath);
Add(_noResolveVsToolsPath);
- Add(_rootArgument);
+ Add(_rootsArgument);
}
protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
@@ -56,39 +74,57 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell
base.Console = new SpectreConsoleOutput(options.LogLevel);
- var rootPath = parseResult.GetValue(_rootArgument) ?? parseResult.GetValue(_rootOption);
- if (string.IsNullOrWhiteSpace(rootPath))
+ var rootPaths = parseResult.GetValue(_rootsArgument) ?? Array.Empty();
+ var singleRootFromOption = parseResult.GetValue(_rootArgument) ?? parseResult.GetValue(_rootOption);
+
+ // Combine argument roots with option root if specified
+ var allRootPaths = new List();
+ if (rootPaths.Any())
+ {
+ allRootPaths.AddRange(rootPaths);
+ }
+ if (!string.IsNullOrWhiteSpace(singleRootFromOption))
+ {
+ allRootPaths.Add(singleRootFromOption);
+ }
+
+ // Default to current directory if no roots specified
+ if (!allRootPaths.Any())
{
- rootPath = Environment.CurrentDirectory;
+ allRootPaths.Add(Environment.CurrentDirectory);
}
var outputFile = parseResult.GetValue(_outputOption);
var updateExisting = parseResult.GetValue(_updateOption);
- return await CreateSlnxFileAsync(rootPath, outputFile, updateExisting, options);
+ return await CreateSlnxFileAsync(allRootPaths, outputFile, updateExisting, options);
}
- private async Task CreateSlnxFileAsync(string rootPath, string? outputFile, bool updateExisting, CleaningOptions options)
+ private async Task CreateSlnxFileAsync(List rootPaths, string? outputFile, bool updateExisting, CleaningOptions options)
{
try
{
- // Determine output file path
- var rootDir = Path.GetFullPath(rootPath);
+ // Determine output file path - use first root path for location if not specified
+ var primaryRootDir = Path.GetFullPath(rootPaths.First());
if (string.IsNullOrEmpty(outputFile))
{
- var dirName = Path.GetFileName(rootDir);
- outputFile = Path.Combine(rootDir, $"{dirName}.slnx");
+ // When multiple roots, use current directory name for the solution name
+ var currentDirName = Path.GetFileName(Environment.CurrentDirectory);
+ outputFile = Path.Combine(Environment.CurrentDirectory, $"{currentDirName}.slnx");
}
else if (!Path.IsPathFullyQualified(outputFile))
{
- outputFile = Path.Combine(rootDir, outputFile);
+ outputFile = Path.Combine(Environment.CurrentDirectory, outputFile);
}
Console.WriteInfo($"Creating/updating slnx file: {outputFile}");
- Console.WriteInfo($"Scanning for projects in: {rootDir}");
+ foreach (var rootPath in rootPaths)
+ {
+ Console.WriteInfo($"Scanning for projects in: {Path.GetFullPath(rootPath)}");
+ }
- // Discover all project files
- var projectFiles = await DiscoverProjectFilesAsync(rootDir, options);
+ // Discover all project files from all root paths
+ var projectFiles = await DiscoverProjectFilesAsync(rootPaths, options);
if (!projectFiles.Any())
{
@@ -128,27 +164,38 @@ private async Task CreateSlnxFileAsync(string rootPath, string? outputFile,
}
}
- private async Task> DiscoverProjectFilesAsync(string rootPath, CleaningOptions options)
+ private async Task> DiscoverProjectFilesAsync(List rootPaths, CleaningOptions options)
{
var projectFiles = new List();
var extensions = new[] { "*.csproj", "*.fsproj", "*.vbproj" };
- foreach (var extension in extensions)
+ foreach (var rootPath in rootPaths)
{
- var files = Directory.EnumerateFiles(rootPath, extension, new EnumerationOptions
+ var fullRootPath = Path.GetFullPath(rootPath);
+ if (!Directory.Exists(fullRootPath))
{
- IgnoreInaccessible = true,
- MatchCasing = MatchCasing.CaseInsensitive,
- MatchType = MatchType.Win32,
- MaxRecursionDepth = options.Depth,
- RecurseSubdirectories = true,
- ReturnSpecialDirectories = false
- });
-
- projectFiles.AddRange(files);
+ Console.WriteWarning($"Root path does not exist: {fullRootPath}");
+ continue;
+ }
+
+ foreach (var extension in extensions)
+ {
+ var files = Directory.EnumerateFiles(fullRootPath, extension, new EnumerationOptions
+ {
+ IgnoreInaccessible = true,
+ MatchCasing = MatchCasing.CaseInsensitive,
+ MatchType = MatchType.Win32,
+ MaxRecursionDepth = options.Depth,
+ RecurseSubdirectories = true,
+ ReturnSpecialDirectories = false
+ });
+
+ projectFiles.AddRange(files);
+ }
}
- return projectFiles;
+ // Remove duplicates that might occur if root paths overlap
+ return projectFiles.Distinct().ToList();
}
private async Task> ParseProjectsAsync(List projectFiles, CleaningOptions options)
From 3bfb4c5250cef27eea5a2e83679d4b39ce691d72 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 19 Sep 2025 20:43:04 +0000
Subject: [PATCH 5/5] Add Git commit status-based project categorization with
obsolete grouping
Co-authored-by: dlosch <318550+dlosch@users.noreply.github.com>
---
bld.slnx | 2 +-
bld/Commands/SlnxCommand.cs | 169 ++++++++++++++++++++++++++++++++----
2 files changed, 154 insertions(+), 17 deletions(-)
diff --git a/bld.slnx b/bld.slnx
index a8636af..37470ad 100644
--- a/bld.slnx
+++ b/bld.slnx
@@ -4,7 +4,7 @@
-
+
\ No newline at end of file
diff --git a/bld/Commands/SlnxCommand.cs b/bld/Commands/SlnxCommand.cs
index 7694f86..1e83bc8 100644
--- a/bld/Commands/SlnxCommand.cs
+++ b/bld/Commands/SlnxCommand.cs
@@ -40,12 +40,19 @@ internal sealed class SlnxCommand : BaseCommand
}
};
+ private readonly Option _obsoleteThresholdOption = new Option("--obsolete-threshold", "-t")
+ {
+ Description = "Threshold in months for considering projects obsolete based on last git commit. Default is 6 months.",
+ DefaultValueFactory = _ => 6
+ };
+
public SlnxCommand(IConsoleOutput console) : base("slnx", "Create or update a .slnx file with all projects organized by type.", console)
{
Add(_rootOption);
Add(_depthOption);
Add(_outputOption);
Add(_updateOption);
+ Add(_obsoleteThresholdOption);
Add(_logLevelOption);
Add(_vsToolsPath);
Add(_noResolveVsToolsPath);
@@ -96,11 +103,12 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell
var outputFile = parseResult.GetValue(_outputOption);
var updateExisting = parseResult.GetValue(_updateOption);
+ var obsoleteThresholdMonths = parseResult.GetValue(_obsoleteThresholdOption);
- return await CreateSlnxFileAsync(allRootPaths, outputFile, updateExisting, options);
+ return await CreateSlnxFileAsync(allRootPaths, outputFile, updateExisting, obsoleteThresholdMonths, options);
}
- private async Task CreateSlnxFileAsync(List rootPaths, string? outputFile, bool updateExisting, CleaningOptions options)
+ private async Task CreateSlnxFileAsync(List rootPaths, string? outputFile, bool updateExisting, int obsoleteThresholdMonths, CleaningOptions options)
{
try
{
@@ -136,7 +144,7 @@ private async Task CreateSlnxFileAsync(List rootPaths, string? outp
// Parse projects and categorize them
var projectInfos = await ParseProjectsAsync(projectFiles, options);
- var categorizedProjects = CategorizeProjects(projectInfos);
+ var categorizedProjects = CategorizeProjects(projectInfos, obsoleteThresholdMonths);
// Generate slnx content
var slnxContent = GenerateSlnxContent(categorizedProjects);
@@ -198,6 +206,108 @@ private async Task> DiscoverProjectFilesAsync(List rootPath
return projectFiles.Distinct().ToList();
}
+ private DateTime? GetLastCommitDate(string projectPath)
+ {
+ try
+ {
+ var projectDir = Path.GetDirectoryName(projectPath);
+ if (string.IsNullOrEmpty(projectDir))
+ return null;
+
+ // Find the git repository root by walking up the directory tree
+ var gitRepoPath = FindGitRepository(projectDir);
+ if (string.IsNullOrEmpty(gitRepoPath))
+ return null;
+
+ // Get the last commit date for files in the project directory and its subdirectories
+ var relativePath = Path.GetRelativePath(gitRepoPath, projectDir).Replace('\\', '/');
+
+ var processStartInfo = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "git",
+ Arguments = $"log -1 --format=%ct -- \"{relativePath}\"",
+ WorkingDirectory = gitRepoPath,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using var process = System.Diagnostics.Process.Start(processStartInfo);
+ if (process == null)
+ return null;
+
+ var output = process.StandardOutput.ReadToEnd().Trim();
+ var error = process.StandardError.ReadToEnd().Trim();
+ process.WaitForExit();
+
+ if (process.ExitCode != 0 || string.IsNullOrEmpty(output))
+ {
+ // If no commits found for the specific path, get the last commit for the entire repo
+ processStartInfo.Arguments = "log -1 --format=%ct";
+ using var fallbackProcess = System.Diagnostics.Process.Start(processStartInfo);
+ if (fallbackProcess == null)
+ return null;
+
+ output = fallbackProcess.StandardOutput.ReadToEnd().Trim();
+ fallbackProcess.WaitForExit();
+
+ if (fallbackProcess.ExitCode != 0 || string.IsNullOrEmpty(output))
+ return null;
+ }
+
+ if (long.TryParse(output, out var unixTimestamp))
+ {
+ return DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).DateTime;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteVerbose($"Failed to get git commit date for {projectPath}: {ex.Message}");
+ }
+
+ return null;
+ }
+
+ private string? FindGitRepository(string startPath)
+ {
+ var currentPath = Path.GetFullPath(startPath);
+
+ while (!string.IsNullOrEmpty(currentPath))
+ {
+ var gitPath = Path.Combine(currentPath, ".git");
+ if (Directory.Exists(gitPath) || File.Exists(gitPath))
+ {
+ return currentPath;
+ }
+
+ var parentPath = Path.GetDirectoryName(currentPath);
+ if (parentPath == currentPath) // Reached root
+ break;
+
+ currentPath = parentPath;
+ }
+
+ return null;
+ }
+
+ private string GetObsoleteSubFolder(DateTime? lastCommitDate)
+ {
+ if (!lastCommitDate.HasValue)
+ return "Unknown";
+
+ var monthsOld = (int)((DateTime.Now - lastCommitDate.Value).TotalDays / 30.44); // Average days per month
+
+ return monthsOld switch
+ {
+ < 6 => "Recent", // This shouldn't happen in obsolete group, but safety net
+ >= 6 and < 12 => "6-12 Months",
+ >= 12 and < 24 => "1-2 Years",
+ >= 24 and < 36 => "2-3 Years",
+ >= 36 => "3+ Years"
+ };
+ }
+
private async Task> ParseProjectsAsync(List projectFiles, CleaningOptions options)
{
var projectInfos = new List();
@@ -308,24 +418,40 @@ private async Task> ParseProjectsAsync(List projectFil
};
}
- private Dictionary> CategorizeProjects(List projects)
+ private Dictionary> CategorizeProjects(List projects, int obsoleteThresholdMonths)
{
- var categorized = new Dictionary>();
+ var categorized = new Dictionary>();
+ var obsoleteThreshold = DateTime.Now.AddMonths(-obsoleteThresholdMonths);
foreach (var project in projects)
{
- var type = project.SlnxProjectType;
- if (!categorized.ContainsKey(type))
+ var lastCommitDate = GetLastCommitDate(project.ProjectPath);
+ var isObsolete = lastCommitDate.HasValue && lastCommitDate.Value < obsoleteThreshold;
+
+ string categoryKey;
+ if (isObsolete)
+ {
+ var subFolder = GetObsoleteSubFolder(lastCommitDate);
+ categoryKey = $"Obsolete/{subFolder}";
+ Console.WriteVerbose($"Project {project.ProjectName} marked as obsolete (last commit: {lastCommitDate?.ToString("yyyy-MM-dd") ?? "unknown"})");
+ }
+ else
{
- categorized[type] = new List();
+ var type = project.SlnxProjectType;
+ categoryKey = GetFolderName(type);
}
- categorized[type].Add(project);
+
+ if (!categorized.ContainsKey(categoryKey))
+ {
+ categorized[categoryKey] = new List();
+ }
+ categorized[categoryKey].Add(project);
}
return categorized;
}
- private string GenerateSlnxContent(Dictionary> categorizedProjects)
+ private string GenerateSlnxContent(Dictionary> categorizedProjects)
{
var doc = new XDocument(
new XDeclaration("1.0", "utf-8", null),
@@ -340,12 +466,17 @@ private string GenerateSlnxContent(Dictionary
var solutionElement = doc.Root!;
+ // Sort categories: non-obsolete first, then obsolete
+ var sortedCategories = categorizedProjects
+ .OrderBy(kvp => kvp.Key.StartsWith("Obsolete/") ? 1 : 0)
+ .ThenBy(kvp => kvp.Key);
+
// Add projects organized by folders
- foreach (var category in categorizedProjects.OrderBy(kvp => kvp.Key.ToString()))
+ foreach (var category in sortedCategories)
{
- var folderName = GetFolderName(category.Key);
+ var folderName = category.Key;
- if (category.Value.Count == 1 && category.Key == SlnxProjectType.Unknown)
+ if (category.Value.Count == 1 && folderName == "Other")
{
// Don't create a folder for single unknown projects
var project = category.Value.First();
@@ -393,12 +524,18 @@ private string GetRelativeProjectPath(string projectPath)
return relativePath.Replace('\\', '/'); // Use forward slashes for consistency
}
- private void DisplaySummary(Dictionary> categorizedProjects)
+ private void DisplaySummary(Dictionary> categorizedProjects)
{
Console.WriteInfo("\nProject Summary:");
- foreach (var category in categorizedProjects.OrderBy(kvp => kvp.Key.ToString()))
+
+ // Sort categories: non-obsolete first, then obsolete
+ var sortedCategories = categorizedProjects
+ .OrderBy(kvp => kvp.Key.StartsWith("Obsolete/") ? 1 : 0)
+ .ThenBy(kvp => kvp.Key);
+
+ foreach (var category in sortedCategories)
{
- var folderName = GetFolderName(category.Key);
+ var folderName = category.Key;
var count = category.Value.Count;
Console.WriteInfo($" {folderName}: {count} project{(count == 1 ? "" : "s")}");