TFS Powershell cmdlets default to -noprompt

March 16th, 2009

The title kinda says it all.  But as usual, there’s a backstory to tell and some ramifications to warn you about.

First, let’s look back at the legacy tf.exe command line.  In the effort to be both user friendly and flexible, its behavior has actually become quite complicated.  As of TFS 2008, the logic appears to be evaluated in this order of precedence:

  1. If the /prompt flag is present for this command, always use Prompt mode, otherwise
  2. If the /noprompt flag is present for this command, always use Noprompt mode, otherwise
  3. If the tf.exe process’ stdout stream is redirected, use Noprompt mode (unless the environment variable TFS_IGNORESTDOUTREDIRECT is set), otherwise
  4. If executing in script mode with a @command file, use Noprompt mode (unless the most recent invocation of the setnoprompt command was “setnoprompt false”), otherwise
  5. Default to Prompt mode

Yikes.  I’d actually forgotten just how many variables there were until I started running tests for this blog post.  Suffice to say, the Powershell cmdlets approach things in a much simpler way:

  1. If the –prompt switch is present for this cmdlet, always use Prompt mode, otherwise
  2. Default to Noprompt mode

Simple enough.  I know a lot of power users will rejoice at this, having long since resigned themselves to /i muscle memory lest random WinForms interrupt their console workflow.  However, there are ramifications to this behavior that you should be aware of.  “Prompt mode” means more than simply popping up dialogs.  First of all, there are some UI features that are still not accessible in Prompt mode without additional parameters, such as tf diff /configure.  More importantly, leaving Prompt mode turns off a number of safeguards:

  • Undo will permanently discard your local changes.  (I think TFS should use the Recycle Bin, but that’s another show…)
  • Shelve /delete will permanently delete shelvesets.  (The server used to keep them around until a cleanup job ran, but again that’s the way things work now.)
  • Destroy, well, destroys.

These aren’t the only examples; you get the idea.  None of this behavior is new to TFS in general, but with the default settings of the Powershell tool, it’s a lot easier to shoot yourself in the foot.  Even good old Checkin is likely to surprise you the first time you see it simply go without any further interaction.  [a “no blank comments” checkin policy might help here, nudge nudge] 

So have fun, just be careful.  If you’re using the power tools then you are ready to be a power user, right? :)

Why is the TFS Powershell snapin marked 32-bit only?

March 13th, 2009

Short answer: because the TFS core assemblies are marked 32-bit only.

Like everyone else, I wish the TFS team had supported x64 much earlier on.  You can see in Microsoft.TeamFoundation.Common.dll that while there are a decent number of routines under the NativeMethods static class that need to be ported, it’s not an enormous task.  Turns out that supporting setup and deployment on x64 servers (with all the requisite expansion of the test matrix, etc.) is by far the higher cost.  Good news is that x64 is supposed to be on the Rosario feature list.

In the meantime, I can’t have John Robbins’ impression of our power tool be negative!  I consider my trip to attend his “Native Windows Debugging” class one of the highlights of my MS career.  As a TFS power user I frequently live in x86 shells, so here’s a tidbit from my $profile that helps keep me sane:

$isX86Process = ( $env:Processor_Architecture -eq "x86" ) ... $Global:PscxHostTitlePreference = { $(if ($IsAdmin) { "Admin: " }) + $PscxWindowTitlePrefix + $(get-location).Path + $(if ($IsX86Process) {" (x86)"}) }

Naturally, I like Vivek’s auto-Fork example too – may need to incorporate somewhere into my library of stuff…

How to diff a file that’s not in your workspace?

March 11th, 2009

The documentation for “tf diff” indicates that it should be possible to do things like
tf diff / c:\file.txt $/project/file.txt

Unfortunately, this syntax is not supported.  (Neither is the /workspace parameter, in case you were wondering.)  It’s an oversight I hope will be corrected in the future.  Until then, here’s a quick fix:

tf view $/project/file.txt > temp.txt
tf diff c:\file.txt temp.txt
del temp.txt

This technique should be easy to put into your scripting language of choice.

More on ConvertTo-FixedByte vs Powershell Community Extensions

February 25th, 2009

Last time we introduced two undocumented cmdlets that shipped with the TFS Fall ‘08 power tools.  The idea behind ConvertTo-FixedByte is probably familiar to anyone who’s used Format-Byte from PSCX.  Take a number and make it pretty.  That’s it -- even the dirt simple conversion from “0” to “<DIR>” is handled in the formatter XML, not the cmdlet.

However, there are some key differences:

  • ConvertTo-FixedByte lets you specify any width between 6 and 22, while Format-Byte is fixed at 10 characters.
  • ConvertTo-FixedByte allows the entire UInt64 range (~10^20), while Format-Byte only goes up to TB (~10^15).
  • ConvertTo-FixedByte does not allow pipeline input, while Format-Byte does.
  • ConvertTo-FixedByte uses the SI standard prefixes, as recommended by IEC, IEEE, EU, and NIST.  Format-Byte uses binary-based prefixes (although it does not display them as KiB, MiB, etc).
    PS> convertto-fixedbyte 2000 -width 10
          2 KB
    PS> format-byte 2000
      1.953 KB
  • ConvertTo-FixedByte uses the biggest possible unit so long as the value is at least one unit.  Format-Byte waits until you’ve exceeded 2^n to use the next biggest unit.
    PS> convertto-fixedbyte 1000 -width 10
          1 KB
    PS> format-byte 1024
       1024 B
  • Both cmdlets attempt to output a fixed width by rounding, but Format-Byte’s approach fails to handle some edge cases correctly:
    PS> convertto-fixedbyte (1000*999) -width 10
        999 KB
    PS> convertto-fixedbyte (1000*999+1) -width 10
    999.001 KB
    PS> convertto-fixedbyte (1000*999+499) -width 10
    999.499 KB
    PS> convertto-fixedbyte (1000*999+500) -width 10
          1 MB
    PS> convertto-fixedbyte (1000*999+501) -width 10
          1 MB
    PS> convertto-fixedbyte (1000*1000-1) -width 10
          1 MB
    PS> convertto-fixedbyte (1000*1000) -width 10
          1 MB
    PS> convertto-fixedbyte (1000*1000+1) -width 10
          1 MB
    PS> format-byte (1024*1023)
       1023 KB
    PS> format-byte (1024*1023+1)
    1023.001 KB
    PS> format-byte (1024*1023+511)
    1023.499 KB
    PS> format-byte (1024*1023+512)
    1023.5 KB
    PS> format-byte (1024*1023+513)
    1023.501 KB
    PS> format-byte (1024*1024-1)
    1023.999 KB
    PS> format-byte (1024*1024)
       1024 KB
    PS> format-byte (1024*1024+1)
          1 MB
  • Both cmdlets omit trailing 0’s, instead padding the front with extra spaces.  [thought there was a slight difference here, but now I can’t repro it!]
  • And of course: PSCX is open-source, while the Power Tools are not.  So I can’t read the PSCX source and Keith can’t read mine.  Even so, I hope some of the ideas can cross-pollinate :)

How does the TFS snap-in handle formatting?

February 25th, 2009

Powershell formatting is pretty flexible, as long as you know how it works.  So how do the version control cmdlets work?  Open up %programfiles(x86)%\Microsoft Team Foundation Server 2008 Power Tools\PowerShell\Microsoft.TeamFoundation.PowerTools.PowerShell.format.ps1xml and have a look for yourself.  Let’s consider the ubiquitous Item class for now:

<View> <Name>Item</Name> <ViewSelectedBy> <TypeName>Microsoft.TeamFoundation.VersionControl.Client.Item</TypeName> </ViewSelectedBy> <TableControl> <TableHeaders> <TableColumnHeader> <Width>7</Width> <Alignment>Right</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>CheckinDate</Label> <Width>10</Width> <Alignment>Right</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>ContentLength</Label> <Width>7</Width> <Alignment>Right</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>ServerItem</Label> <Alignment>Left</Alignment> </TableColumnHeader> </TableHeaders> <TableRowEntries> <TableRowEntry> <TableColumnItems> <TableColumnItem> <PropertyName>ChangesetId</PropertyName> </TableColumnItem> <TableColumnItem> <ScriptBlock>$_.CheckinDate.ToString('d')</ScriptBlock> </TableColumnItem> <TableColumnItem> <ScriptBlock> if ($_.ItemType -eq [Microsoft.TeamFoundation.VersionControl.Client.ItemType]::Folder) { '&lt;DIR&gt;' } else { ConvertTo-FixedByte $_.ContentLength 7 } </ScriptBlock> </TableColumnItem> <TableColumnItem> <ScriptBlock> ConvertTo-FixedPath $_.ServerItem 7,10,6 </ScriptBlock> </TableColumnItem> </TableColumnItems> </TableRowEntry> </TableRowEntries> </TableControl> </View>

And a few examples of what it looks like, taken from an 80- and a 112-character-wide console:

PS> tfdir Changes CheckinDat Content ServerItem etId e Length ------- ---------- ------- ---------- 8224 2/25/2009 <DIR> $/Test-ConchangoV2/super/u...g/directorynamefromhell 8224 2/25/2009 11 KB $/Test-ConchangoV2/super/u...efromhell/devilsurf.jpg 8224 2/25/2009 12 B $/Test-ConchangoV2/super/u...orynamefromhell/foo.txt 8224 2/25/2009 5 MB $/Test-ConchangoV2/super/u.../PowerGUI. PS> tfdir Changes CheckinDat Content ServerItem etId e Length ------- ---------- ------- ---------- 8224 2/25/2009 <DIR> $/Test-ConchangoV2/super/ultra/very/very/mega/long/directorynamefromhell 8224 2/25/2009 11 KB $/Test-ConchangoV2/super/ultra/very/very/ 8224 2/25/2009 12 B $/Test-ConchangoV2/super/ultra/very/very/mega/long/directorynamefromhell/foo.txt 8224 2/25/2009 5 MB $/Test-ConchangoV2/super/ultra/very/very/m...torynamefromhell/PowerGUI.

You can see that the Powershell team’s “expand toward the right to minimize visual variance” concept is used to great effect.  However, we violated their “never provide a label” maxim in 3 out of 4 columns.  Oops!  Seriously though, I don’t think anyone would want to use a ScriptProperty with the exact parameters found in the formatter XML.  What we provided instead is far more useful: a couple of fully generic cmdlets named ConvertTo-FixedByte and ConvertTo-FixedPath.  I’ll cover the former in another post; there’s a fair amount of detail to dig into, but its use here is rather mundane. 

ConvertTo-FixedPath is what really makes this example shine.  If you’re like me, you hate managing long paths in shell environments.  Wrapping onto multiple lines, truncating the front, and (especially) truncating the end of the path are all unacceptable.  Cmdlets to the rescue!  This one has two modes (“parameter sets” in PS-speak).  The first is obvious enough:

PS> convertto-fixedpath 12345678901234567890 -width 16


The 20-character input string is truncated to 16, but only by omitting things in the middle.  (Note that this mode has an off-by-one bug.  Hint: odd numbers.)  This allows you to answer the important questions: what is the file name? what branch/workspace are they located in?  Usually we don’t care as much about the intermediate directories.  Maybe the same is true for other kinds of strings too – let me know if you find it useful!

The other mode is designed explicitly for use in a ps1xml formatter.  Consider:

PS> convertto-fixedpath 12345678901234567890 -otherwidths 30, 20, 10


Same output, but what the heck are the “other widths”?  Imagine this string were going into a variable-length field.  We want the string to expand dynamically, showing us as much of the filename as possible.  Yet if it does need to be cropped, we want it done intelligently, as if we’d known the width in advance and passed it as a –width parameter.

As it turns out, we can know the final width, given a little hackery.  The Powershell API lets you access the raw console host details, including its BufferSize.  All you need to do is subtract the widths of the other columns, plus room for the separator character PS places between each column.  So imagine the code above is used in a four-column formatter: 80 - 30 - 20 - 10 - (4*1) = 16.  Sure enough, if you scroll back up to the XML, you’ll see the values passed to –otherwidths are no coincidence.  We hand-tweaked every column width so it fit the data type as closely as possible without wrapping*, then plugged in those widths to the convertto-fixedpath cmdlet driving the final variable-length column.

This hack may not always work as Powershell migrates away from the ancient CSRSS hosting environment.  But in theory, anyone who implements PSHostRawUserInterface should be compatible.  It works in the Powershell v2 ISE environment that ships with Win7, for instance.  Custom-PS-tool-lovers, let me know how it goes.

*Except for column titles, obviously.  They were a necessary casualty of two higher-priority design considerations (a) keeping labels identical to the underlying property name (b) supporting the default 80-character console width without squishing average-sized paths.

Powershell: find SQL files orphaned from SSMS projects

February 23rd, 2009

We’re using SQL Management Studio to manage our database scripts.  (Yes, VS “Data Dude” is under evaluation, but it has lots of quirks we haven’t had time to get a grasp on yet.)  Since making DB development a collaborative, source-controlled process is new itself, we needed some extra checks & balances.  I remembered that James had a script for reconciling project files with what’s actually on disk.  Although SSMS doesn’t use msbuild for its project structure, it was still fairly easy to adapt.

function find-orphan {
  param([string]$proj = $(throw 'ssmssqlproj file is required'))
  $proj = resolve-path $proj
  $dir = split-path $proj
  $xml = [xml](cat $proj)
  $files_from_proj = ($xml.SqlWorkbenchSqlProject.Items.LogicalFolder | 
    where { $ -eq "Queries" }).items.filenode |
    foreach { join-path $dir $ } | 
  $files_from_dir = ls $dir -r -filter *.sql |
    foreach { $_.fullname } |
  compare $files_from_proj $files_from_dir

How to enforce a rule on Area/Iteration Path

February 17th, 2009

Another pitfall I encountered while setting up our new team project was how to make sure people didn’t cop out and leave them at the default (root, aka useless) value.  With Areas I may have been able to set up some weird non-inheriting permission scheme, but I needed to solve this problem for Iterations anyway, so I turned to the web. 

Unfortunately, the standard answer from the product team didn’t work for me. 

<FIELD type="Integer" name="AreaID" refname="System.AreaId"> <HELPTEXT>AreaID</HELPTEXT> </FIELD> <FIELD type="Integer" name="IterationID" refname="System.IterationId"> <HELPTEXT>IterationID</HELPTEXT> </FIELD> <FIELD type="String" name="Product Area - Validation" refname="Coatue.ProductAreaValidation"> <HELPTEXT>Hidden field used to validate Product Area</HELPTEXT> <WHEN field="System.AreaId" value="84"> <COPY from="value" value="Restricted" /> </WHEN> <PROHIBITEDVALUES> <LISTITEM value="Restricted" /> </PROHIBITEDVALUES> </FIELD> <FIELD type="String" name="Sprint or Release - Validation" refname="Coatue.SprintPathValidation"> <HELPTEXT>Hidden field used to validate Sprint or Release</HELPTEXT> <WHEN field="System.IterationId" value="84"> <COPY from="value" value="Restricted" /> </WHEN> <PROHIBITEDVALUES> <LISTITEM value="Restricted" /> </PROHIBITEDVALUES> </FIELD>

It successfully blocked work items that had Area/Iteration paths I didn’t want, but I’d continue to get validation errors after I changed the paths in the form to something else.

At this point I started to wonder whether the AreaId and AreaPath fields were as intricately tied as I thought they were.  After all, I didn’t have any rules saying to update one when the other changed.  (The documentation kinda implies this but doesn’t spell out the mechanism.)  So I decided to test at the object model level: Get-TfsServer OM wrapper to the rescue!

PS C:\Users\rberg> $tfs = get-tfsserver njtfs –all
PS C:\Users\rberg> $bug = $tfs.wit.GetWorkItem(1333)
PS C:\Users\rberg> $bug.AreaId; $bug.IterationId


PS C:\workspaces\ws1> $bug.AreaPath; $bug.IterationPath
Test-ConchangoV2\Release 1\Sprint 4

PS C:\Users\rberg> $bug.fields | where { $"Validation") } | select value
Restricted Restricted                                                                                                                       

PS C:\Users\rberg> $bug.AreaId = 104; $bug.IterationId = 91
PS C:\workspaces\ws1> $bug.AreaPath; $bug.IterationPath
Test-ConchangoV2\Release 1\Sprint 4 

PS C:\Users\rberg> $bug.fields | where { $"Validation") } | select value

Nope, not a synchronization issue between path on the form <–> underlying ID.  Looks like I really do need to tweak Gregg’s advice.  All you need is a corresponding WHENNOT rule to cancel out each WHEN rule as it’s no longer needed.  Here’s what the hidden validator for Area looks like now:

<FIELD type="String" name="Product Area - Validation" refname="Coatue.ProductAreaValidation"> <HELPTEXT>Hidden field used to validate Product Area</HELPTEXT> <WHEN field="System.AreaId" value="84"> <COPY from="value" value="Restricted" /> </WHEN> <WHENNOT field="System.AreaId" value="84"> <COPY from="value" value="Ok" /> </WHENNOT> <PROHIBITEDVALUES> <LISTITEM value="Restricted" /> </PROHIBITEDVALUES> </FIELD>

Make the same change for Iteration and you’re golden.

What does Get-TfsServer –All do, exactly?

February 17th, 2009

PS C:\workspaces\ws1> help tfserver –full

    Get-TfsServer [-Name] <String> [-Credential <PSCredential>] [-All] [<Common

    Get-TfsServer -Path <String> [-Credential <PSCredential>] [-All] [<CommonPa


    Get Team Foundation Server object with the extended properties

  Required?                    false
    Position?                    named
    Default value
    Accept pipeline input?       false
    Accept wildcard characters?  false


Not the greatest help text ever written, I know.  Oh well, there’s a lot more room on this blog anyway :)

First the basics: Get-TfsServer and Get-TfsWorkspace (aliases: tfserver, tfworkspace) are intended to be “official” versions of James Manning’s get-tfs.ps1 and get-workspace.ps1 cmdlets.  To incorporate them into the main snap-in, Hyung ported them to C# and standardized the parameters.  Both will now accept either –Path or –Name (but not both).  For tfserver, –Name is the default [position = 1] and all others are named, while the reverse is true for tfworkspace, reflecting what we felt was the most common use of each.  Bottom line, these will all work:

> $tfs = get-tfsserver njtfs
> $tfs = get-tfsserver –path .
> $ws = get-tfsworkspace .
> $ws = get-tfsworkspace –name njtfs
> $ws = get-tfsworkspace –servername .

There are also new parameters like –Credential on tfserver, and –Computer and –Owner on tfworkspace, which behave more or less how you’d expect.  In short, you can use tfserver to interface TFS with Powershell’s built-in credential management features, as well as tfworkspace to query for the Workspace object that represents remote workspaces you may not even own.

So back to the original topic.  The –All switch turns on the special script-friendly behavior that James pioneered and I later extended.


As you can see there are a few differences between the final shipping behavior and the various get-tfs.ps1 scripts floating on the web:

  • Once you choose –All, every TFS assembly is loaded immediately, rather than on-demand as you use the ScriptProperties.  The idea here is to “fail early” rather than introduce a ticking bomb into downstream scripts that may depend on these objects.
  • The types that we import as NoteProperties are prefixed with the same shorthand as the ScriptProperties, plus an underscore.  Even though we continue to weed out types that aren’t useful (anything private, an exception, or an event) there are still a ton to sort thru; this tweak helps segregate the huge list into “namespaces” that are still marginally navigable with Tab completion.
  • C# is a much uglier language to do this work than Powershell!

All in all, you can now do cool things like:

PS C:\workspaces\ws1> $tfs = get-tfsserver njtfs -all
PS C:\workspaces\ws1> $tfs.vcs.GetChangeset(1234) 

Changes Owner                  CreationDa Comment
   etId                                te
------- -----                  ---------- -------
   1234 COATUECAP\LDeGrazia    10/27/2007

PS C:\workspaces\ws1> new-object $tfs.VCS_ChangesetVersionSpec 1234

                            ChangesetId DisplayString
                            ----------- -------------
                                   1234 C1234

So why not have this option switched on permanently?  The vanilla TeamFoundationServer object isn’t very useful on its own; the entire point of James’ original wrapper was to quickly access the VersionControlServer and similar objects.  Well first of all, it’s noticeably slower – on my machine, testing a “cold” Powershell console, the cmdlet takes 3 seconds with the default parameters and 6 seconds with –All.  We’re loading more assemblies than ever, not to mention deep reflection on each one (in order to test an upcast and a string comparison, not exactly speedy operations in themselves).  More importantly, the extra properties add enormous clutter to the objects when not needed.  And I think you’ll be more likely to use those TFS objects in vanilla form in your future scripts.  For example, most of the version control cmdlets in the Fall 2008 suite take a TeamFoundationServer as either pipeline input or a named parameter.  I’m sure whatever Microsoft is cooking up for the next batch will include even more interactivity and flow between cmdlets.

How to prevent people from opening new bugs

February 17th, 2009

We’re in the middle of transitioning to a new team project.  On the source control side, Microsoft has made a huge investment to support arbitrary renames throughout the tree.  No reason to mess with TFS->TFS migration when I can move the native items!  On the other hand, migrating work items across team projects is notoriously tricky.  I decided it would be easiest to steadily phase out the work items remaining in the old project while forcing people to open new issues in the new project.

Being relatively new to WIT customization, the answer wasn’t immediately obvious.  TFS doesn’t let you stick an ALLOWEDVALUES rule on fields like System.CreatedDate that the system populates on creation.  And anything you stick on a user-editable field will inevitably block users from navigating an existing work item through the rest of its legitimate workflow.  The path I took was modifying the initial transition from “”.  XML excerpt:

<TRANSITION from="" to="Active"> <REASONS> <REASON value="Build Failure" /> <DEFAULTREASON value="New" /> </REASONS> <FIELDS> <FIELD refname="Microsoft.VSTS.Common.ActivatedBy"> <COPY from="currentuser" /> <VALIDUSER /> <REQUIRED /> </FIELD> <FIELD refname="Microsoft.VSTS.Common.ActivatedDate"> <SERVERDEFAULT from="clock" /> </FIELD> <FIELD refname="System.AssignedTo"> <DEFAULT from="currentuser" /> </FIELD> <FIELD refname="System.Title"> <ALLOWEDVALUES> <LISTITEM value="Before 2009-02-12" /> </ALLOWEDVALUES> </FIELD> </FIELDS> </TRANSITION>

Most of this comes from the stock MSF Agile v4.2 template; the System.Title field with the strange rule toward the end is my addition.  Doesn’t really matter what you put in the value, so long as you don’t tell your users what it is :)

Unlocking other people’s files. Yes, again.

February 14th, 2009

You’re going about your business, trying to refactor some code, when suddenly things come to a crashing halt.  “The item foo.cs is locked for check-out by DOMAIN\user in workspace ws1.”  Since Microsoft shipped the first public betas of TFS in 2005, there have probably been more blog posts on this pitfall than any other -- I’ll pick on James for an example.  The frustration also spawned Status Sidekick (now part of TFS Sidekicks), the first 3rd-party TFS tool to my knowledge. 

The arrival of the Fall 2008 Powertools hinted at yet another possibility based on Powershell.  Script-friendly unlocking is particularly enticing for scenarios like the one I found myself in last week: transitioning a team from exclusive checkouts to an edit-merge-commit workflow.  Turning off the exclusive settings on the Team Project is the obvious first step, but what to do about the hundreds of existing pending changes?  I can’t ask everyone to suddenly checkin their work (which may be far from ready to share, or even build), but I also can’t reorganize the tree unless every lock is removed.  Thus:

del temp.hat 2> $null foreach ($pendingSet in get-tfspendingchange -user *) { $workspace = '"' + $pendingSet.Name + '";' + $pendingSet.OwnerName $pendingSet.PendingChanges | where {$_.IsLock} | foreach { $item = '"' + $_.ServerItem + '"' "lock /lock:none /workspace:$workspace $item" | out-file temp.hat -append } } tf "@temp.hat" del temp.hat

This isn’t the cleanest solution in the world.  You couldn’t just wire up the “find all locks in the system” example I provided for Brian’s post to a Remove-TfsLock cmdlet -- even if the Power Tools provided one (which they don’t yet).  By convention, a pipeline of TFS cmdlets transmits QualifiedItems, but to unlock an arbitrary item we need two additional pieces of information from the PendingSet object.  So we fall back on good old procedural loops around tf lock.

Could we improve the script?  Sure.  We could batch ~12 items at a time* for a better performance, though the gain is not nearly as big as using a @hatfile in the first place.  We could also be a little more robust.  Get-TfsPendingChange returns a full PendingSet in the general case, but sometimes the return type decays to a simple PendingChange[].  If our query returns exactly 1 workspace, then our logic for populating $workspace won’t work. 

Of course, we could also do a lot worse!  I originally tried to add the wrapper quotes in-place, but quickly got into syntax trouble.  After consulting the relevant Powershell blog post I eventually got it right, but it was an unreadable mess.  For example, line 4 read:

$workspace = "`"$($pendingSet.Name)`";$($pendingSet.OwnerName)"


*3200 character limit on each line of a tf.exe script file; TFS paths are 260 chars max