How to add a project to a solution in PowerShell

The last few days I've been living in the Powershell terminal at work. We are in the process of migrating an on-prem TFS project to VSTS, but doing so also revising our +49 projects structure.

To prepare the new structure, I wrote a Powershell migration script which will copy-transform-generate our new layout. One of the tasks in the entire process is to create a folder layout where we will copy an empty Solution.sln file and also Robocopy the existing project folder(s).

Next, we want to add our projects to the Solution.sln file from within the script so that when you open the solution, you don't have manually insert the projects yourself.

I looked at a lot of posts on the internet, and most show examples on how to use EnvDte (Visual Studio SDK) to open a solution and do some "stuff." However, none of the samples I found were working for me. So eventually after puzzling the various pieces together, I came up with the following.

[void][System.Reflection.Assembly]::LoadWithPartialName("EnvDTE") 

function AddMessageFilterClass 
{ 
$source = @" 
 
namespace EnvDteUtils{ 
 
using System; 
using System.Runtime.InteropServices; 
    public class MessageFilter : IOleMessageFilter 
    { 
        // 
        // Class containing the IOleMessageFilter 
        // thread error-handling functions. 
 
        // Start the filter. 
        public static void Register() 
        { 
            IOleMessageFilter newFilter = new MessageFilter();  
            IOleMessageFilter oldFilter = null;  
            CoRegisterMessageFilter(newFilter, out oldFilter); 
        } 
 
        // Done with the filter, close it. 
        public static void Revoke() 
        { 
            IOleMessageFilter oldFilter = null;  
            CoRegisterMessageFilter(null, out oldFilter); 
        } 
 
        // 
        // IOleMessageFilter functions. 
        // Handle incoming thread requests. 
        int IOleMessageFilter.HandleInComingCall(int dwCallType,  
          System.IntPtr hTaskCaller, int dwTickCount, System.IntPtr  
          lpInterfaceInfo)  
        { 
            //Return the flag SERVERCALL_ISHANDLED. 
            return 0; 
        } 
 
        // Thread call was rejected, so try again. 
        int IOleMessageFilter.RetryRejectedCall(System.IntPtr  
          hTaskCallee, int dwTickCount, int dwRejectType) 
        { 
            if (dwRejectType == 2) 
            // flag = SERVERCALL_RETRYLATER. 
            { 
                // Retry the thread call immediately if return >=0 &  
                // <100. 
                return 99; 
            } 
            // Too busy; cancel call. 
            return -1; 
        } 
 
        int IOleMessageFilter.MessagePending(System.IntPtr hTaskCallee,  
          int dwTickCount, int dwPendingType) 
        { 
            //Return the flag PENDINGMSG_WAITDEFPROCESS. 
            return 2;  
        } 
 
        // Implement the IOleMessageFilter interface. 
        [DllImport("Ole32.dll")] 
        private static extern int  
          CoRegisterMessageFilter(IOleMessageFilter newFilter, out  
          IOleMessageFilter oldFilter); 
    } 
 
    [ComImport(), Guid("00000016-0000-0000-C000-000000000046"),  
    InterfaceTypeAttribute(ComInterfaceType.InterfaceIsIUnknown)] 
    interface IOleMessageFilter  
    { 
        [PreserveSig] 
        int HandleInComingCall(  
            int dwCallType,  
            IntPtr hTaskCaller,  
            int dwTickCount,  
            IntPtr lpInterfaceInfo); 
 
        [PreserveSig] 
        int RetryRejectedCall(  
            IntPtr hTaskCallee,  
            int dwTickCount, 
            int dwRejectType); 
 
        [PreserveSig] 
        int MessagePending(  
            IntPtr hTaskCallee,  
            int dwTickCount, 
            int dwPendingType); 
    } 
} 
"@ 
Add-Type -TypeDefinition $source 
 
} 

function AttachProjectBulk{
    param([String[]]$projectFolders, [String]$sln)
    write-host "Attaching project to solution:"
    write-host $projectFolders
    write-host $sln

    write-host "Starting Visual studio SDK"
    $dteObj = New-Object -ComObject "VisualStudio.DTE.14.0"
    [EnvDTEUtils.MessageFilter]::Register();
    $sol = $dteObj.Solution;
    write-host $("Opening solution: {0}" -f $sln)
    $sol.Open($sln);

    foreach($project in $projectFolders){
        if(Test-Path $project){
            write-host $("Adding project {0} " -f $project)
            $sol.AddFromFile($project,$false);
        }
    }

    $sol = $dteObj.Solution;
    write-host $("Saving solution: {0}" -f $sln)
    $sol.SaveAs($sln);
    $dteObj.Quit();
    [EnvDTEUtils.MessageFilter]::Revoke()       
}

This script has 2 functions:

  • AddMessageFilterClass
  • AttachProjectBulk

AddMessageFilterClass

This function uses C# to create a Type that will be registered called MessageFilter.

I found this on MSDN with the following explanation:

If you programmatically call into Visual Studio automation from an external (out-of-proc), multi-threaded application, you may receive the following errors:

  • Application is busy (RPC_E_CALL_REJECTED 0x80010001)
  • Call was rejected by callee (RPC_E_SERVERCALL_RETRYLATER 0x8001010A)

These errors occur due to threading contention issues between external multi-threaded applications and Visual Studio. We eliminate them by implementing IOleMessageFilter error handlers in your Visual Studio automation application. (Do not confuse IOleMessageFilter with System.Windows.Forms.IMessageFilter.)

source: https://msdn.microsoft.com/en-us/library/ms228772.aspx

AttachProjectBulk

AttachProjectBulk is the actual function which will attach or import an existing project into the solution.

It takes two parameters:

  1. An array of String instances which are the full paths to the *.csproj files
  2. A String which represents the full path to the *.sln file to which you want to add the project(s).

Some more on what's happening here:

First, we print out some information about the parameters that are passed into the function.
Next, we create a new instance of the EnvDte by calling New-Object on the COM+ object VisualStudio.DTE.14.0 => which is Visual Studio .net 2015 (note that if you have another version, you'll need to change this accordingly)

$dteObj = New-Object -ComObject "VisualStudio.DTE.14.0"

Note that this will start a devenv.exe process in the background.

Then we use our MessageFilter class we created in the previous function and call its Register function, so we don't run into these pesky multi-threading issues.

[EnvDTEUtils.MessageFilter]::Register();

We obtain a reference to the Solution instance of the EnvDte and open the solution

$sol = $dteObj.Solution;
write-host $("Opening solution: {0}" -f $sln)
$sol.Open($sln);

All this is the same as opening a solution in Visual Studio. If you were to show the window (which you by manipulating $dteObj.MainWindow) you would see an empty solution.

Next, we loop our array of project paths and for each project call the AddFromFile on the Solution instance.

foreach($project in $projectFolders){
   if(Test-Path $project){
      write-host $("Adding project {0} " -f $project)
      $sol.AddFromFile($project,$false);
    }
}

Then all we need to do is save the Solution, terminate the devenv.exe process and revoke the MessageFilter

write-host $("Saving solution: {0}" -f $apsln)
$sol.SaveAs($apsln);
$dteObj.Quit();
[EnvDTEUtils.MessageFilter]::Revoke()       

Don't forget to call $dteObj.Quit() or the devenv.exe will keep running!

Now when you open the solution, you should see the added project(s).

Usage

Use the following snippet to add all the projects under the current folder to a Solution file called mywork.sln:


...the above script...
#Register the MessageFilter type
AddMessageFilterClass

#Get all csproj files in the current folder and all subfolders and get their full path using their FullName
$arrayOfProjects = Get-ChildItem . -Filter "*.csproj" -Recurse | ForEach-Object{ $_.FullName}

#Attach the projects to the solution
AttachProjectBulk $arrayOfProjects "./mywork.sln"