Simplest TFS cmdlet - history of a local file

I got 2 requests in the last week to explain cmdlets and show how to create one, so I figured I'd throw together a simple one for a Version Control api (QueryHistory), since that's what my blog has historically focused on.  The cmdlets I'm doing "for real" these days are focused on TFS management (configuring/administering the server, ala the Exchange 2007 story) and aren't overly interesting to most people.  Admittedly, there are plenty of other example cmdlets out there already, but one more won't hurt :)

We go and create our new class library project (I'll do it in C#, but you can choose your own language of course).  What references do we need?  These are all in the GAC, so life is pretty simple (sorry, I fully disagree with VS core's choice that intra-GAC assemblies shouldn't be project references - the design-time vs. run-time distinction is really academic and petty to me)

  • For the installer support (RunInstaller attribute, usage of Installutil.exe), we need System.Configuration.Install
  • For PowerShell stuff (Cmdlet, PSSnapIn, etc.) we need System.Management.Automation
  • For our usage of Team Foundation, we need 3 TFS assemblies

This gives us references of:

     <Reference Include="System" />
    <Reference Include="System.Configuration.Install" />
    <Reference Include="System.Management.Automation" />
    <Reference Include="Microsoft.TeamFoundation.Client" />
    <Reference Include="Microsoft.TeamFoundation.VersionControl.Client" />
    <Reference Include="Microsoft.TeamFoundation.VersionControl.Common" />

First off, cmdlets typically need to be within snapins, so we create a snap for this cmdlet.  Normally you'd have many cmdlets (and/or providers, etc.) within a given snapin, but we'll include one to make this a more complete (and usable) example.

     [RunInstaller(true)]
    public class GetTfHistorySnapIn : PSSnapIn
    {
        public override string Name { get { return "Get.TfHistory"; } }
        public override string Vendor { get { return "Microsoft Corporation"; } }
        public override string Description{ get { return "This snapin contains 1 cmdlet called get-tfhistory"; } }
    }

Now, for the cmdlet itself.  First, we need to pick the verb and noun.  Since we're getting something from a data source, we'll use the common "Get" verb.  In terms of the noun of what we're getting, it's history, but get-history is already taken by PS itself, and "history" is a bit of an overloaded term.  Because of that, we'll use "TfHistory" for now.  Naturally, long-term you'd probably choose TeamFoundationVersionControlHistory or something like that (since, for example, work items have history too), but we'll keep it simple for this example.

     [Cmdlet(VerbsCommon.Get, "TfHistory")]
    public class GetTfHistoryCommand : PSCmdlet
    {

Ok, now we've declared the cmdlet - what kind of parameters will we take?  Checking the QueryHistory method, there's many we could specify, but we're trying to keep a simple example, so I'm just going to specify a single one - the item you want the history of (that seems like the simplest possible case).  We want to validate that they don't specify a null or empty string (not very useful) and we also want to say that we're ok with them specifying the item via the pipeline.

         private string m_item;

        [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true), ValidateNotNullOrEmpty]
        public string Item
        {
            get { return m_item; }
            set { m_item = value; }
        }

Since a mapped local item also tells us (thanks to the cache file) the server and workspace involved, we don't even have to specify the server for that case, so we'll even take the additional restriction (for now) that it has to be a mapped local item.

So, with that we can write the only other thing we need - an override for the ProcessRecord method.

To review, we want to:

  • Make sure it's not a server path (since server path doesn't imply a workspace/server - we could check current dir, but we're being lazy and keeping the example simple)
  • Make sure it's a mapped local path (we want to get the server info from the cache file)
    • Note, these 2 restrictions aren't "real", I'm just being lazy and it gives me a chance to show usage of ThrowTerminatingError :)
  • call QueryHistory for the item (no recursion, assume latest version, assume current user, etc)
  • write the Changeset objects we get back
         protected override void ProcessRecord()
        {
            if (VersionControlPath.IsServerItem(m_item))
            {
                ThrowTerminatingError(new ErrorRecord(new ArgumentException("Currently only supports mapped local paths"), "only local", ErrorCategory.InvalidArgument, m_item));
            }
            WorkspaceInfo wsInfo = Workstation.Current.GetLocalWorkspaceInfo(m_item);
            if (wsInfo == null)
            {
                ThrowTerminatingError(new ErrorRecord(new ArgumentException("Specified path is not mapped"), "not mapped", ErrorCategory.InvalidArgument, m_item));
            }

            TeamFoundationServer tfs = new TeamFoundationServer(wsInfo.ServerUri.AbsoluteUri);
            VersionControlServer vcs = (VersionControlServer)tfs.GetService(typeof(VersionControlServer));
            Workspace workspace = vcs.GetWorkspace(wsInfo);

            string serverPath = workspace.GetServerItemForLocalItem(m_item);
            IEnumerable changesets = vcs.QueryHistory(serverPath, VersionSpec.Latest, 0, RecursionType.None, null,
                                                      null, null, int.MaxValue, true, false);
            foreach (Changeset changeset in changesets)
            {
                WriteObject(changeset);
            }
        }

That's it - that's a full and working cmdlet.  Now how do we get it into our shell?  First we need to use installutil the dll to get it installed.

C:\Users\jmanning\Documents\Visual Studio 2005\projects\GetTfHistoryCommand\GetTfHistoryCommand\bin\Debug# installutil GetTfHistoryCommand.dll
Microsoft (R) .NET Framework Installation utility Version 2.0.50727.312
Copyright (c) Microsoft Corporation. All rights reserved.

Running a transacted installation.

Beginning the Install phase of the installation.
[...snip...]
The Commit phase completed successfully.

The transacted install has completed.
C:\Users\jmanning\Documents\Visual Studio 2005\projects\GetTfHistoryCommand\GetTfHistoryCommand\bin\Debug#

Now we can open a new PowerShell and see that our snapin has been registered

C:\Users\jmanning# get-pssnapin -registered

Name : Get.TfHistory
PSVersion : 1.0
Description : This snapin contains 1 cmdlet called get-tfhistory

Excellent - now we can add-pssnapin to get it into our shell, then we can actively start using our cmdlet.

C:\Users\jmanning# add-pssnapin Get.TfHistory

First, let's check out our failure cases to make sure they work as expected.  This via screen shot just to show the colors.

Now for the success case - i pick off just the changesetid and owner to keep the output from being too cluttered.  Unfortunately, Windows Live Writer kinda messes up the formatting here (the leading spaces), but hopefully you get the idea.

C:\Users\jmanning# get-tfhistory 'D:\dd\tsadt_2\src\dd.sln' | ft -a changesetid,owner

ChangesetId Owner
----------- -----
60522 NORTHAMERICA\rhorvick
60413 REDMOND\davgray
44044 NORTHAMERICA\rhorvick

Since we specified that we're fine with the value coming from the pipeline, we can show that case working, too.

C:\Users\jmanning# 'D:\dd\tsadt_2\src\vset' | get-tfhistory | ft -a changesetid,owner

ChangesetId Owner
----------- -----
147970 NORTHAMERICA\buckh
141789 NORTHAMERICA\chandrur
60522 NORTHAMERICA\rhorvick
60413 REDMOND\davgray
43037 NORTHAMERICA\rhorvick

You can also notice how PowerShell calls our ProcessRecord for each item passed, so we can specify 2 different items and get the history for both in a single line.  They're done back-to-back since we're calling ProcessRecord twice here, so there's 2 different QueryHistory calls - we could "sort -uniq" the output if we wanted to get the unique changesets in the union.

C:\Users\jmanning# 'D:\dd\tsadt_2\src\vset','D:\dd\tsadt_2\src\dd.sln' | get-tfhistory | ft -a changesetid,owner

ChangesetId Owner
----------- -----
147970 NORTHAMERICA\buckh
141789 NORTHAMERICA\chandrur
60522 NORTHAMERICA\rhorvick
60413 REDMOND\davgray
43037 NORTHAMERICA\rhorvick
60522 NORTHAMERICA\rhorvick
60413 REDMOND\davgray
44044 NORTHAMERICA\rhorvick

Note that you can go via pipeline or passed param, but not both.

C:\Users\jmanning# 'xxx' | Get-TfHistory 'yyy'
Get-TfHistory : The input object cannot be bound to any parameters for the command either because the command does not
take pipeline input or the input and its properties do not match any of the parameters that take pipeline input.
At line:1 char:22
+ 'xxx' | Get-TfHistory <<<< 'yyy'

What if we didn't want to have to add-pssnapin all the time?  You can use export-console to create the necessary psc1 file then.   Then, when we started PowerShell we could supply -psconsolefile TfHistorySnapin.psc1 and the snapin would already be added and ready to use.  This is what Exchange 2007 (and others) do so their snapins are already available when you run PowerShell.

C:\Users\jmanning# export-console TfHistorySnapin
C:\Users\jmanning# cat TfHistorySnapin.psc1
<?xml version="1.0" encoding="utf-8"?>
<PSConsoleFile ConsoleSchemaVersion="1.0">
<PSVersion>1.0</PSVersion>
<PSSnapIns>
<PSSnapIn Name="Get.TfHistory" />
</PSSnapIns>
</PSConsoleFile>

Well, that's about all there is to cmdlets - as an exercise for the reader, you can fill out the cmdlet.  Things that come to mind:

  • Accept the same params as tf history (except ones that don't make sense given the scenario, like /format, /noprompt)
  • Accept server paths (check current dir for a mapping, and if not, require /server param)
  • Include a types xml to change the default formatting of changesets

GetTfHistoryCommand.zip