Skip to content

Commit 167fb0c

Browse files
committed
Fail CI when System.CommandLine setup is broken
1 parent 15a16b2 commit 167fb0c

File tree

3 files changed

+124
-1
lines changed

3 files changed

+124
-1
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace TfvcMigrator.Tests;
2+
3+
public static class EntryPointTests
4+
{
5+
[Test]
6+
public static async Task No_System_CommandLine_failure_for_minimal_arguments()
7+
{
8+
var arguments = await CommandVerifier.VerifyArgumentsAsync(async () =>
9+
await Program.Main(new[] { "http://someurl", "$/SomePath", "--authors", "authors.txt" }));
10+
11+
arguments[0].ShouldBe(new Uri("http://someurl"));
12+
arguments[1].ShouldBe("$/SomePath");
13+
arguments[2].ShouldBe("authors.txt");
14+
}
15+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using System.Reflection;
2+
using System.Reflection.Emit;
3+
4+
namespace TfvcMigrator;
5+
6+
public static class CommandVerifier
7+
{
8+
private static readonly AsyncLocal<Action<object?[]>?> ActiveInterceptor = new();
9+
private static readonly FieldInfo ActiveInterceptorField = typeof(CommandVerifier).GetField(nameof(ActiveInterceptor), BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly)!;
10+
11+
public static async Task<object?[]> VerifyArgumentsAsync(Func<Task> invokeInterceptedDelegateAsync)
12+
{
13+
var arguments = (object?[]?)null;
14+
15+
var previousValue = ActiveInterceptor.Value;
16+
ActiveInterceptor.Value = interceptedArguments =>
17+
{
18+
if (arguments is not null)
19+
throw new InvalidOperationException("The intercepted delegate was invoked more than once.");
20+
21+
arguments = interceptedArguments;
22+
};
23+
try
24+
{
25+
await invokeInterceptedDelegateAsync().ConfigureAwait(false);
26+
}
27+
finally
28+
{
29+
ActiveInterceptor.Value = previousValue;
30+
}
31+
32+
return arguments ?? throw new InvalidOperationException("The intercepted delegate was not invoked.");
33+
}
34+
35+
public static Delegate Intercept(Delegate @delegate)
36+
{
37+
if (@delegate.Target is not null)
38+
throw new ArgumentException("Only static lambdas and static methods are supported.", nameof(@delegate));
39+
40+
var parameters = @delegate.Method.GetParameters();
41+
42+
// System.Linq.Expressions creates a method with an initial closure parameter which System.CommandLine
43+
// doesn't tolerate.
44+
var interceptingMethod = new DynamicMethod(@delegate.Method.Name, @delegate.Method.ReturnType, Array.ConvertAll(parameters, p => p.ParameterType));
45+
46+
for (var i = 0; i < parameters.Length; i++)
47+
{
48+
var parameter = parameters[i];
49+
interceptingMethod.DefineParameter(i + 1, parameter.Attributes, parameter.Name);
50+
}
51+
52+
var generator = interceptingMethod.GetILGenerator();
53+
54+
var interceptorLocal = generator.DeclareLocal(typeof(Action<object[]>));
55+
56+
generator.Emit(OpCodes.Ldsfld, ActiveInterceptorField);
57+
generator.Emit(OpCodes.Callvirt, ActiveInterceptorField.FieldType.GetProperty(nameof(ActiveInterceptor.Value), BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)!.GetMethod!);
58+
generator.Emit(OpCodes.Stloc, interceptorLocal);
59+
generator.Emit(OpCodes.Ldloc, interceptorLocal);
60+
61+
var noInterceptionLabel = generator.DefineLabel();
62+
generator.Emit(OpCodes.Brfalse_S, noInterceptionLabel);
63+
64+
// Call the intercepting delegate
65+
generator.Emit(OpCodes.Ldloc, interceptorLocal);
66+
generator.Emit(OpCodes.Ldc_I4, parameters.Length);
67+
generator.Emit(OpCodes.Newarr, typeof(object));
68+
69+
for (var i = 0; i < parameters.Length; i++)
70+
{
71+
generator.Emit(OpCodes.Dup); // object[]
72+
generator.Emit(OpCodes.Ldc_I4, i);
73+
generator.Emit(OpCodes.Ldarg, i);
74+
75+
if (parameters[i].ParameterType is { IsValueType: true } valueType)
76+
generator.Emit(OpCodes.Box, valueType);
77+
78+
generator.Emit(OpCodes.Stelem_Ref);
79+
}
80+
81+
generator.Emit(OpCodes.Callvirt, interceptorLocal.LocalType.GetMethod("Invoke", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)!);
82+
83+
if (@delegate.Method.ReturnType != typeof(void))
84+
{
85+
if (@delegate.Method.ReturnType == typeof(Task))
86+
{
87+
generator.Emit(OpCodes.Call, typeof(Task).GetProperty(nameof(Task.CompletedTask), BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)!.GetMethod!);
88+
}
89+
else
90+
{
91+
throw new NotImplementedException();
92+
}
93+
}
94+
95+
generator.Emit(OpCodes.Ret);
96+
97+
// Call original method
98+
generator.MarkLabel(noInterceptionLabel);
99+
100+
for (var i = 0; i < parameters.Length; i++)
101+
generator.Emit(OpCodes.Ldarg, i);
102+
103+
generator.Emit(OpCodes.Call, @delegate.Method);
104+
generator.Emit(OpCodes.Ret);
105+
106+
return interceptingMethod.CreateDelegate(@delegate.GetType());
107+
}
108+
}

src/TfvcMigrator/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public static Task Main(string[] args)
3636
new Option<string?>("--pat") { Description = "Optional PAT, required to access TFVC repositories hosted on Azure DevOps Services. If not provided Default Client Credentials will be used, these are only suitable for on-premise TFS/Azure DevOps Server." },
3737
};
3838

39-
command.Handler = CommandHandler.Create(MigrateAsync);
39+
command.Handler = CommandHandler.Create(CommandVerifier.Intercept(MigrateAsync));
4040

4141
return command.InvokeAsync(args);
4242
}

0 commit comments

Comments
 (0)