Compare commits

...
Author SHA1 Message Date
cf2660b6b2 tests(git-provider): Add fact tests for empty name registrations 2026-08-10 16:01:15 +10:00
09236e826a tests(git-provider): Add basic registration tests
- make ModuleCore internals visible to ModuleTests
2026-08-10 15:50:24 +10:00
b9f856e716 feat(git-provider): First pass of creating directories from New-GitRepo command
- move GitManager to ModuleCore
- refactor SetGitRepoCommand to own file
2026-08-10 14:54:15 +10:00
1363f6ce02 chore(git-provider): explore setting the location based on a child fragment match 2026-08-07 16:49:30 +10:00
dedea9213b refactor(git-provider): Basic manager class implementation and dummy Set-GitRepo command 2026-08-07 14:58:16 +10:00
6193d30029 feat(git-provider): add Sqlite to PowershellModule
- add CopyLocalLockFileAssemblies to PowershellModule.csproj
- add postbuild script to copy Sqlite files and remove unneeded files in debug output
- add sqlite-net-pcl 1.11.285
2026-08-07 11:05:14 +10:00
830ae2c560 feat(git-provider): add inital implementation for New-GitRepo 2026-08-07 08:51:54 +10:00
1b09d15937 feat(git-provider): More placeholder overrides for future use 2026-08-06 15:31:17 +10:00
05d6eece99 chore(powershell-harness): refactor command code, add git provider to runspace, output command results to console 2026-08-06 14:50:09 +10:00
e6665a588b feat(git-provider): Add basic GitRepo provider 2026-08-06 14:44:24 +10:00
15 changed files with 589 additions and 8 deletions

View file

@ -4,6 +4,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="sqlite-net-pcl" Version="1.11.285" />
<PackageVersion Include="System.IO.Packaging" Version="10.0.10" /> <PackageVersion Include="System.IO.Packaging" Version="10.0.10" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.10" /> <PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.10" />
<PackageVersion Include="Microsoft.PowerShell.SDK" Version="7.6.4" /> <PackageVersion Include="Microsoft.PowerShell.SDK" Version="7.6.4" />

View file

@ -0,0 +1,162 @@
namespace ModuleCore.Git;
// TODO: better name for this
public class GitManager
{
private static readonly Lazy<GitManager> GitManagerInstance = new(() => new GitManager());
public static GitManager Instance => GitManagerInstance.Value;
/// <summary>
/// Always returns a new clean instance of GitManager
/// </summary>
internal static GitManager InternalFreshInstance => new();
/// <summary>
/// Simply <see cref="Path.DirectorySeparatorChar"/>.ToString()
/// </summary>
private static readonly string DirectorySeparator = Path.DirectorySeparatorChar.ToString();
private readonly InternalDirectory _repositories;
private readonly Lock _readWriteLock = new();
private GitManager()
{
Console.WriteLine($"{nameof(GitManager)} init");
// Initialise the root container
_repositories = new InternalDirectory()
{
Name = DirectorySeparator,
InternalPath = DirectorySeparator
};
}
/// <summary>
/// Registers a git repository based on an absolute location. If <paramref name="registrationName"/> is null or empty,
/// the registration will use the folder name for the git repo at the top level.
/// </summary>
/// <param name="absoluteRepositoryLocation"></param>
/// <param name="registrationName"></param>
/// <returns>The normalised string the repository was registered against</returns>
public string RegisterRepo(string absoluteRepositoryLocation, string registrationName)
{
// Depending on the caller, it might be possible that they've scripted automatic repo registration. Because I
// don't really want to account to all the subtle ways that can be parallised, I just naively lock on every
// registration attempt. This method should be quick regardless, and I could use ConcurrentDictionary except
// that means every instance of InternalDirectory would need it and yeah nah fuck that I can just lock at the
// top level
lock (_readWriteLock)
{
var normalisedName = NormaliseNamePath(string.IsNullOrWhiteSpace(registrationName)
? new DirectoryInfo(absoluteRepositoryLocation).Name
: registrationName);
// Regardless of if we get a name or not, the fully qualified version for us
// starts with a /
var directorySegmentsFromName = NameToSegments(normalisedName);
var added = _repositories.Add(absoluteRepositoryLocation, directorySegmentsFromName);
// Not sure about this, the Add should throw any exceptions on duplicate/failures but for now I'll leave this
// here
if (added == null)
{
throw new Exception("Failed to register location");
}
return normalisedName;
}
}
/// <summary>
/// Takes a name and returns it as a queue of its parts, starting with a root of <see cref="DirectorySeparator"/>
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
private Queue<string> NameToSegments(string name)
{
var segments = name.Split(DirectorySeparator);
return segments.Length == 1
? new Queue<string>([DirectorySeparator, name])
: new Queue<string>([DirectorySeparator, ..segments]);
}
/// <summary>
/// Normalises the path separators in the given string to use Path.DirectorySeparatorChar
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
private string NormaliseNamePath(string name)
{
// Feels a bit hacky, but this will actually normalise a path to a valid form. So if the input is
// some/directory/paths, Path.GetRelativePath will normalise it to some\directory\paths, relative to ./
// which is kind of handy but I also just wish there was a Path method that would do this for me. I know that
// the whole point of Path is that it's based on a file system, but file systems can also be arbitrary and not
// always be drive rooted.
// Either way, this works and saves me having to reimplement a worse method when it's more important that users
// are able to use file paths in whatever form they prefer, which means we leverage the internal implementation
// in a weird way.
return Path.GetRelativePath("./", name);
}
private class InternalDirectory
{
/// <summary>
/// Name of the folder this
/// </summary>
public string Name { get; set; } = null!;
public Dictionary<string, InternalDirectory> Children { get; set; } = [];
internal string InternalPath { get; set; }
/// <summary>
/// If not null, this is the absolute location of a registered git repository
/// </summary>
public string? FullRepositoryPath { get; set; }
/// <summary>
///
/// </summary>
/// <param name="absoluteRepositoryLocation"></param>
/// <param name="directorySegmentsFromName"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
internal InternalDirectory? Add(string absoluteRepositoryLocation, Queue<string> directorySegmentsFromName)
{
var topStack = directorySegmentsFromName.Dequeue();
if (topStack != Name)
{
// logically it shouldn't be possible to have a value on top of the stack that _doesn't_ exist, but
// just incase we throw as this should only happen if an Add is attempted on the root and the queue was
// not correctly rooted to /
throw new Exception($"Directory segment does not seem to exist: {topStack}");
}
// We're at the end of the directory segments so we can safely say we're at the end of the tree so
// we add it to the relevant dictionary
if (directorySegmentsFromName.Count == 0)
{
FullRepositoryPath = absoluteRepositoryLocation;
return this;
}
var nextSegment = directorySegmentsFromName.Peek();
// Attempt to get the next level of the directory. If we don't have a key entry, create one
if (!Children.TryGetValue(nextSegment, out var nextChild))
{
nextChild = new InternalDirectory()
{
Name = nextSegment,
InternalPath = Path.Combine(InternalPath, nextSegment)
};
Children.Add(nextSegment, nextChild);
}
// add the next
return nextChild.Add(absoluteRepositoryLocation, directorySegmentsFromName);
}
}
}

View file

@ -7,4 +7,10 @@
<LangVersion>latestmajor</LangVersion> <LangVersion>latestmajor</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ModuleTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project> </Project>

View file

@ -2,6 +2,7 @@
using System.Management.Automation.Runspaces; using System.Management.Automation.Runspaces;
using System.Text; using System.Text;
using PowershellModule.Calendar; using PowershellModule.Calendar;
using PowershellModule.Git;
namespace PowershellHarness; namespace PowershellHarness;
@ -36,8 +37,26 @@ class Program
var host = new CustomHost(Console.WindowWidth); var host = new CustomHost(Console.WindowWidth);
var runspace = InitialisePowershellHost(host); var runspace = InitialisePowershellHost(host);
// InvokeCommand(runspace, GetCalendarCommand.FullName); host.UI.SetNextPromptChoice(3);
InvokeCommand(runspace, "New-PSDrive", [
CreateCommand("name", "git-test"),
CreateCommand("PSProvider", "GitRepo"),
CreateCommand("Root", "\\"),
]);
InvokeCommand(runspace, "Set-Location",
[
// Technically this can just be the command but this is a bit easier
CreateCommand("path", "git-test:/")
]);
InvokeCommand(runspace, "Get-Location");
}
private static void CalendarTestCommands(Runspace runspace)
{
InvokeCommand(runspace, GetCalendarCommand.FullName);
foreach (var day in Enum.GetValues<DayOfWeek>()) foreach (var day in Enum.GetValues<DayOfWeek>())
{ {
InvokeCommand(runspace, GetCalendarCommand.FullName, [ InvokeCommand(runspace, GetCalendarCommand.FullName, [
@ -48,11 +67,15 @@ class Program
]); ]);
} }
// InvokeCommand(runspace, GetCalendarCommand.FullName, [ InvokeCommand(runspace, GetCalendarCommand.FullName, [
// new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"), new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"),
// new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), DayOfWeek.Saturday) new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), DayOfWeek.Saturday)
// ]); ]);
// InvokeCommand(runspace, GetCalendarCommand.FullName, [new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), "Sunday")]); InvokeCommand(runspace, GetCalendarCommand.FullName, [new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), "Sunday")]);
}
private static void TestGitProvider(Runspace runspace)
{
} }
private static CommandParameter CreateCommand(string name, string? argument = null) private static CommandParameter CreateCommand(string name, string? argument = null)
@ -97,6 +120,10 @@ class Program
var getCalendarCommand = new SessionStateCmdletEntry(GetCalendarCommand.FullName, typeof(GetCalendarCommand), null); var getCalendarCommand = new SessionStateCmdletEntry(GetCalendarCommand.FullName, typeof(GetCalendarCommand), null);
initialSessionState.Commands.Add(getCalendarCommand); initialSessionState.Commands.Add(getCalendarCommand);
var gitProvider = new SessionStateProviderEntry(GitProvider.Name, typeof(GitProvider), null);
initialSessionState.Providers.Add(gitProvider);
// Create a runspace from the state, open and return it // Create a runspace from the state, open and return it
var runspace = RunspaceFactory.CreateRunspace(host, initialSessionState); var runspace = RunspaceFactory.CreateRunspace(host, initialSessionState);
@ -114,16 +141,29 @@ class Program
// and this is just a debug harness so it doesn't really matter for now // and this is just a debug harness so it doesn't really matter for now
using var pipeline = runspace.CreatePipeline(); using var pipeline = runspace.CreatePipeline();
// using var powershell = PowerShell.Create(runspace); // using var powershell = PowerShell.Create(runspace);
// StringBuilder to store the output of this command including any output results (but not errors yet)
// this is just a rudimentary test and the pwsh debug profile should be used instead as it loads the module
// in a full powershell window with debugger attached. Just no automatic command running sadly.
var sb = new StringBuilder();
sb.Append(command);
var cmd = new Command(command); var cmd = new Command(command);
if (parameters is not null) if (parameters is not null)
{ {
foreach (var commandParameter in parameters) var param = parameters.ToList();
sb.Append(' ')
.AppendJoin(' ', param.Select(x => $"-{x.Name} {x.Value}"));
foreach (var commandParameter in param)
{ {
cmd.Parameters.Add(commandParameter); cmd.Parameters.Add(commandParameter);
} }
} }
sb.AppendLine();
pipeline.Commands.Add(cmd); pipeline.Commands.Add(cmd);
// powershell.Commands.AddCommand(cmd); // powershell.Commands.AddCommand(cmd);
@ -133,8 +173,11 @@ class Program
// var results = powershell.Invoke(); // var results = powershell.Invoke();
foreach (var result in results) foreach (var result in results)
{ {
Console.Write(result); sb.AppendLine(result.ToString());
// Console.WriteLine(result);
} }
Console.WriteLine(sb);
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -0,0 +1,86 @@
using System;
using System.Management.Automation;
using System.Management.Automation.Provider;
namespace PowershellModule.Git;
// https://learn.microsoft.com/en-us/powershell/scripting/developer/provider/accessdbprovidersample02?view=powershell-7.6
[CmdletProvider(Name, ProviderCapabilities.None)]
public class GitProvider : NavigationCmdletProvider
{
public const string Name = "GitRepo";
public GitProvider()
{
}
protected override PSDriveInfo NewDrive(PSDriveInfo drive)
{
// This is a bit of a hack to ensure that no drive is registered with a root location.
// It seems to be how PSDrives are registered for Alias providers, and we technically don't have any concept
// of a normal file system
if (drive.Root != string.Empty)
{
throw new Exception("Drive root must be an empty string");
}
return base.NewDrive(drive);
}
protected override void NewItem(string path, string itemTypeName, object newItemValue)
{
//base.NewItem(path, itemTypeName, newItemValue);
}
protected override PSDriveInfo RemoveDrive(PSDriveInfo drive)
{
return base.RemoveDrive(drive);
}
protected override void GetChildNames(string path, ReturnContainers returnContainers)
{
// TODO: get all configured repos based on a path from the underlying GitPsDriveInfo then
// output their repository names
}
protected override void GetChildItems(string path, bool recurse)
{
// TODO: get all configured repos based on a path from the underlying GitPsDriveInfo then
// output their repository locations and any other meta data I store
}
// I think this is needed to be able to cd into it
protected override bool IsItemContainer(string path)
{
return true;
}
// I think this is needed to be able to cd into it
protected override bool ItemExists(string path)
{
return true;
}
protected override string MakePath(string parent, string child)
{
// Test to see what happens if set the location if the path matches a pretend repo registered with the name test
// The result of the test is that yes, this does work, but there's also a shit load of calls to this method as
// it traverses the entire directory path.
// This seems to just be internal powershell behaviour so as long as this is quick the first time, everything
// afterwards just always happens. But probably happens more seeing as I'm changing the location of the path
// in this current SessionState and probably doesn't happen if this were a normal path traversal
// where I don't set the location.
if (child == "test")
{
SessionState.Path.SetLocation("F:/Repos/PowershellModule/src/PowershellModule/Git");
return "F:/Repos/PowershellModule/src/PowershellModule/Git";
}
return base.MakePath(parent, child);
}
protected override bool IsValidPath(string path)
{
throw new System.NotImplementedException();
}
}

View file

@ -0,0 +1,12 @@
using System.Management.Automation;
namespace PowershellModule.Git;
public class GitPsDriveInfo : PSDriveInfo
{
protected GitPsDriveInfo(PSDriveInfo driveInfo)
: base(driveInfo)
{
}
}

View file

@ -0,0 +1,114 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Management.Automation;
using ModuleCore.Git;
namespace PowershellModule.Git;
[Cmdlet(VerbsCommon.New, Noun)]
public class NewGitRepoCommand : PSCmdlet
{
private const string Noun = "GitRepo";
[Parameter(
Position = 0,
ValueFromPipeline = true,
HelpMessage = "Reference name for the repo")]
public string? Name { get; set; }
public NewGitRepoCommand()
{
Console.WriteLine($"{nameof(NewGitRepoCommand)} init");
}
protected override void BeginProcessing()
{
var pwd = this.SessionState.Path.CurrentLocation.Path;
WriteObject("Checking if current directory is a git repository...");
var repoFolfder = IsGitRepo(pwd);
if (repoFolfder is not null)
{
GitManager.Instance.RegisterRepo(repoFolfder.Directory, Name ?? repoFolfder.Folder);
}
else
{
// Not sure how we'd hit this path, but in case we do, show some sort of error.
// TODO: I should probably have IsGitRepo throw instead so I can get the location it tried in the stack track
WriteError(new ErrorRecord(
new Exception("Unable to register repo - failed to parse git repo location"),
"git-parse-failed",
ErrorCategory.InvalidData,
null
)
);
}
base.BeginProcessing();
}
private ParsedGitFolderDetails? IsGitRepo(string path)
{
var ps = new ProcessStartInfo("git",
["rev-parse", "--show-toplevel"])
{
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = path
};
// If the user doesn't have git on their path, this will throw an exception that I don't have to do anything
// special with, it'll be unhandled and powershell will handle it
var gitProcess = Process.Start(ps);
// This probably shouldn't be possible? Not really sure of the conditions where the process could be started
// but return null, but I'm going to consider that unrecoverable error territory
if (gitProcess is null)
{
throw new Exception("git failed to start");
}
gitProcess.WaitForExit();
if (!gitProcess.StandardOutput.EndOfStream)
{
var directory = gitProcess.StandardOutput.ReadToEnd();
// Gotta trim what we get as it might already have a newline character at the end
var dirInfo = new DirectoryInfo(directory.Trim());
var repoFolderInfo = new ParsedGitFolderDetails
{
Directory = dirInfo.FullName,
Folder = dirInfo.Name,
};
return repoFolderInfo;
}
if (!gitProcess.StandardError.EndOfStream)
{
var errorAsException = new Exception(gitProcess.StandardError.ReadToEnd());
WriteError(new ErrorRecord(errorAsException, "git-not-found", ErrorCategory.FromStdErr, null));
}
return null;
}
/// <summary>
/// The directory details of the directory returned from git rev-parse --show-toplevel
/// </summary>
private class ParsedGitFolderDetails
{
/// <summary>
/// The full path to the top level folder containing a git repository
/// </summary>
public string Directory { get; set; } = null!;
/// <summary>
/// The last folder name of the directory
/// </summary>
public string Folder { get; set; } = null!;
}
}

View file

@ -0,0 +1,23 @@
using System;
using System.Management.Automation;
using ModuleCore.Git;
namespace PowershellModule.Git;
[Cmdlet(VerbsCommon.Set, Noun)]
public class SetGitRepoCommand : PSCmdlet
{
private const string Noun = "GitRepo";
public SetGitRepoCommand()
{
Console.WriteLine($"{nameof(NewGitRepoCommand)} init");
var a = GitManager.Instance;
}
protected override void BeginProcessing()
{
SessionState.Path.SetLocation("F:/Repos/PowershellModule/src/PowershellModule/Git");
base.BeginProcessing();
}
}

View file

@ -0,0 +1,27 @@
<#
.SYNOPSIS
Removes all non-core files from build output
#>
param(
[Parameter(ValueFromPipeline = $true, Position = 0, Mandatory = $true)]
$targetDir
)
$nativeSqliteDllLocation = "$( $targetDir )runtimes\win-x64\native\e_sqlite3.dll";
if ($false -eq (Test-Path $nativeSqliteDllLocation))
{
Write-Error "POST BUILD FAILED: Unable to locate $nativeSqliteDllLocation"
}
Write-Host "Copying '$nativeSqliteDllLocation' to '$targetDir'"
Copy-Item -Path $nativeSqliteDllLocation -Destination $targetDir
# Because we use CopyLocalLockFileAssemblies Files that are needed for the module
$allowList = @(
"ModuleCore*"
"PowershellModule*"
"*SQLite*"
)
Write-Host "Removing all non-module required files from '$targetDir'"
Get-ChildItem -Path $targetDir -exclude $allowList | Remove-Item -Recurse

View file

@ -5,16 +5,22 @@
<AssemblyName>PowershellModule</AssemblyName> <AssemblyName>PowershellModule</AssemblyName>
<LangVersion>latestmajor</LangVersion> <LangVersion>latestmajor</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="PowerShellStandard.Library" > <PackageReference Include="PowerShellStandard.Library" >
<PrivateAssets>All</PrivateAssets> <PrivateAssets>All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="sqlite-net-pcl" />
<PackageReference Include="System.Management.Automation" /> <PackageReference Include="System.Management.Automation" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ModuleCore\ModuleCore.csproj" /> <ProjectReference Include="..\ModuleCore\ModuleCore.csproj" />
</ItemGroup> </ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="pwsh.exe -file &quot;$(ProjectDir)PostBuild.ps1&quot; $(TargetDir)" />
</Target>
</Project> </Project>

View file

@ -0,0 +1,77 @@
using System.Text;
using ModuleCore.Calendar;
using ModuleCore.Git;
using ModuleTests.Git.TestData;
namespace ModuleTests.Git;
public class AddRegistrationTests
{
private static readonly VerifySettings Settings;
static AddRegistrationTests()
{
Settings = new VerifySettings();
var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(AddRegistrationTests));
Settings.UseDirectory(testBaseDirectory);
Settings.DisableDiff();
}
[Theory]
[ClassData(typeof(AddRegistrationTestData))]
public Task BasicRepoRegistration((int testId, string path) testData)
{
Settings.UseFileName($"{nameof(BasicRepoRegistration)}_{testData.testId}");
var gitManager = GitManager.InternalFreshInstance;
var repoRegistration = gitManager.RegisterRepo("Test:/some/test/repo", testData.path);
var sb = new StringBuilder();
sb.AppendLine($"Attempted to register: {testData.path}")
.AppendLine($"Registration result: {repoRegistration}");
return Verify(sb, Settings);
}
[Fact]
public void RepoRegistrationWithEmptyName()
{
Settings.UseFileName(nameof(RepoRegistrationWithEmptyName));
var gitManager = GitManager.InternalFreshInstance;
var testRepoAbsolutePath = "Test:/some/test/repo";
var emptyName = gitManager.RegisterRepo(testRepoAbsolutePath, "");
Assert.Equal("repo", emptyName);
}
[Fact]
public void RepoRegistrationWithNullName()
{
Settings.UseFileName(nameof(RepoRegistrationWithNullName));
var gitManager = GitManager.InternalFreshInstance;
var testRepoAbsolutePath = "Test:/some/test/repo";
// Name is technically not-nullable, but string is a reference type so null can be passed in so we should test
// it regardless
var nullName = gitManager.RegisterRepo(testRepoAbsolutePath, null!);
Assert.Equal("repo", nullName);
}
[Fact]
public void RepoRegistrationWithWhitespaceName()
{
Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName));
var gitManager = GitManager.InternalFreshInstance;
var testRepoAbsolutePath = "Test:/some/test/repo";
var whitespaceName = gitManager.RegisterRepo(testRepoAbsolutePath, " ");
Assert.Equal("repo", whitespaceName);
}
}

View file

@ -0,0 +1,2 @@
Attempted to register: test
Registration result: test

View file

@ -0,0 +1,2 @@
Attempted to register: test\path
Registration result: test\path

View file

@ -0,0 +1,2 @@
Attempted to register: other/path
Registration result: other\path

View file

@ -0,0 +1,18 @@
using System.Linq;
namespace ModuleTests.Git.TestData;
public class AddRegistrationTestData : TestDataEnumerator<(int testId, string path)>
{
public AddRegistrationTestData()
{
Data = new List<string>()
{
"test",
$"test{Path.DirectorySeparatorChar}path",
$"other{Path.AltDirectorySeparatorChar}path"
}
.Select((x, i) => (i, x))
.ToList();
}
}