-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathWorkspace.cs
More file actions
251 lines (219 loc) · 9.63 KB
/
Copy pathWorkspace.cs
File metadata and controls
251 lines (219 loc) · 9.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
// Workspace: collapses sln/csproj/src/dev.json discovery into a single value object.
// Owns .sln vs .slnx precedence, the src/ fallback, submodule detection,
// dev.json parent-directory inheritance, and ignore-list filtering. Callers
// receive a Workspace and use semantic queries (BuildTarget, EnumerateBumpTargets);
// raw SolutionPath/ProjectPath are reserved for envelope serialization.
using System.Text.Json;
using Microsoft.VisualStudio.SolutionPersistence.Model;
using Microsoft.VisualStudio.SolutionPersistence.Serializer;
namespace Dev;
public sealed class Workspace
{
public string RootPath { get; }
public string? SolutionPath { get; }
public string? ProjectPath { get; }
public BuildTarget BuildTarget { get; }
private readonly IReadOnlyList<BumpTarget> _bumpTargets;
private Workspace(
string rootPath,
string? solutionPath,
string? projectPath,
BuildTarget buildTarget,
IReadOnlyList<BumpTarget> bumpTargets)
{
RootPath = rootPath;
SolutionPath = solutionPath;
ProjectPath = projectPath;
BuildTarget = buildTarget;
_bumpTargets = bumpTargets;
}
public IReadOnlyList<BumpTarget> EnumerateBumpTargets() => _bumpTargets;
public static Workspace? Discover(string startPath, Action<string>? log = null)
=> Discover(startPath, log, new RealFileSystem(), new SolutionPersistenceReader());
internal static Workspace? Discover(string startPath, Action<string>? log, IFileSystem fs, ISolutionReader sln)
{
var ignoreProjects = LoadDevConfig(startPath, fs)?.IgnoreProjects;
var (slnFile, csprojFile) = ResolveBuildFiles(startPath, fs);
if (slnFile is null && csprojFile is null)
return null;
var buildTargetPath = slnFile ?? csprojFile!;
var buildTarget = new BuildTarget(
buildTargetPath,
Path.GetFileName(buildTargetPath),
slnFile is not null ? BuildTargetKind.Solution : BuildTargetKind.Project);
var bumpTargets = slnFile is not null
? EnumerateFromSolution(slnFile, ignoreProjects, fs, sln, log)
: new List<BumpTarget>
{
new(csprojFile!, Path.GetFileNameWithoutExtension(csprojFile!), true, null),
};
return new Workspace(startPath, slnFile, csprojFile, buildTarget, bumpTargets);
}
private static (string? Sln, string? Csproj) ResolveBuildFiles(string startPath, IFileSystem fs)
{
var sln = FindSolutionFile(startPath, fs);
var csproj = FindProjectFile(startPath, fs);
if (sln is null && csproj is null)
{
var srcDir = Path.Combine(startPath, "src");
if (fs.DirExists(srcDir))
{
sln = FindSolutionFile(srcDir, fs);
csproj = FindProjectFile(srcDir, fs);
}
}
return (sln, csproj);
}
private static DevConfig? LoadDevConfig(string startPath, IFileSystem fs)
{
var devConfigFile = Path.Combine(startPath, "dev.json");
if (!fs.FileExists(devConfigFile))
{
var parent = fs.GetParentDir(startPath);
if (parent is null) return null;
var parentConfigFile = Path.Combine(parent, "dev.json");
if (!fs.FileExists(parentConfigFile)) return null;
devConfigFile = parentConfigFile;
}
try
{
return JsonSerializer.Deserialize<DevConfig>(
fs.ReadAllText(devConfigFile),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (Exception ex)
{
throw new WorkspaceDiscoveryException("config_parse_error", "Error reading dev.json", ex);
}
}
private static string? FindSolutionFile(string searchPath, IFileSystem fs) =>
fs.GetFiles(searchPath, "*.sln*")
.Where(f => f.EndsWith(".sln", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase))
.OrderBy(f => f.Length)
.FirstOrDefault();
private static string? FindProjectFile(string searchPath, IFileSystem fs) =>
fs.GetFiles(searchPath, "*.csproj").FirstOrDefault();
private static List<BumpTarget> EnumerateFromSolution(
string slnFile,
IReadOnlyCollection<string>? ignoreProjects,
IFileSystem fs,
ISolutionReader sln,
Action<string>? log)
{
var targets = new List<BumpTarget>();
List<string> projectPaths;
try
{
projectPaths = sln.ReadProjectPaths(slnFile).ToList();
}
catch (Exception ex)
{
log?.Invoke($"[red]Error opening solution file:[/] {ex.Message}");
return targets;
}
foreach (var projectPath in projectPaths)
{
var displayName = Path.GetFileNameWithoutExtension(projectPath);
if (IsInGitSubmodule(projectPath, fs, log))
{
log?.Invoke($"[yellow]Skipping[/] {displayName} [yellow](inside git submodule)[/]");
targets.Add(new BumpTarget(projectPath, displayName, false, "submodule"));
continue;
}
if (ignoreProjects?.Any(p => string.Equals(p, displayName, StringComparison.OrdinalIgnoreCase)) == true)
{
log?.Invoke($"[yellow]Skipping[/] {displayName} [yellow](configured in dev.json)[/]");
targets.Add(new BumpTarget(projectPath, displayName, false, "ignored"));
continue;
}
targets.Add(new BumpTarget(projectPath, displayName, true, null));
}
return targets;
}
private static bool IsInGitSubmodule(string filePath, IFileSystem fs, Action<string>? log)
{
try
{
var current = Path.GetDirectoryName(filePath);
while (!string.IsNullOrEmpty(current))
{
var gitPath = Path.Combine(current, ".git");
if (fs.FileExists(gitPath)) return true;
if (fs.DirExists(gitPath)) return false;
current = fs.GetParentDir(current);
}
return false;
}
catch (Exception ex)
{
// Filesystem walks can fail on permission issues, broken symlinks, or
// long paths. Surface the cause via the log channel so an unexpected
// bump (project treated as non-submodule because of an IO error) is
// not silent. The discovery does not abort — we still default to
// "not in submodule" so the project flows through normal handling.
log?.Invoke($"[yellow]Submodule check failed for[/] {filePath}: {ex.Message}");
return false;
}
}
}
public readonly record struct BuildTarget(string Path, string DisplayName, BuildTargetKind Kind);
public enum BuildTargetKind { Solution, Project }
public readonly record struct BumpTarget(string Path, string DisplayName, bool Include, string? SkipReason);
public sealed class WorkspaceDiscoveryException : Exception
{
public string Code { get; }
public WorkspaceDiscoveryException(string code, string message, Exception? inner = null)
: base(message, inner)
=> Code = code;
}
internal interface IFileSystem
{
bool FileExists(string path);
bool DirExists(string path);
string[] GetFiles(string dir, string pattern);
string ReadAllText(string path);
void WriteAllText(string path, string contents);
string? GetParentDir(string path);
}
internal sealed class RealFileSystem : IFileSystem
{
public bool FileExists(string path) => File.Exists(path);
public bool DirExists(string path) => Directory.Exists(path);
public string[] GetFiles(string dir, string pattern) => Directory.GetFiles(dir, pattern);
public string ReadAllText(string path) => File.ReadAllText(path);
public void WriteAllText(string path, string contents) => File.WriteAllText(path, contents);
public string? GetParentDir(string path) => Directory.GetParent(path)?.FullName;
}
internal interface ISolutionReader
{
// Returns project paths already absolute-normalized (backslash → platform separator,
// relative-to-sln → absolute).
IEnumerable<string> ReadProjectPaths(string slnPath);
}
internal sealed class SolutionPersistenceReader : ISolutionReader
{
public IEnumerable<string> ReadProjectPaths(string slnPath)
{
var serializer = SolutionSerializers.GetSerializerByMoniker(slnPath)
?? throw new InvalidOperationException($"Unable to find a serializer for {slnPath}");
// Sync-over-async at the CLI's outer boundary. A .NET console app has no
// captured SynchronizationContext, so `GetAwaiter().GetResult()` cannot
// deadlock the way it can in UI/ASP.NET callers. Keeping the surface sync
// avoids forcing every caller (Workspace.Discover, Program.Main) to
// become async for one I/O call. If this type is ever reused outside the
// CLI, prefer wrapping with `Task.Run(...).GetAwaiter().GetResult()`.
var solution = serializer.OpenAsync(slnPath, CancellationToken.None).GetAwaiter().GetResult();
var slnDir = Path.GetDirectoryName(slnPath) ?? "";
var result = new List<string>();
foreach (var project in solution.SolutionProjects)
{
var projectPath = project.FilePath?.Replace('\\', Path.DirectorySeparatorChar);
if (string.IsNullOrEmpty(projectPath)) continue;
if (!Path.IsPathRooted(projectPath))
projectPath = Path.Combine(slnDir, projectPath);
result.Add(projectPath);
}
return result;
}
}