Tuesday, August 6, 2013

Episode #169: Move Me Maybe

Tim checks the mailbag

Carlos IHaveNoLastName writes in asking for a way to move a directory to a new destination. That's easy, but the directory should only be moved if the the directory (at any depth) does NOT contain a file with a specific extenstion.

Here is an example of a sample directory structure:

SomeTopDir1
|-OtherDir1
|  |-File1
|  |-File2
|  |-File2
|-OtherDir2
   |-File1
   |-File.inprogress

SomeTopDir2
|-OtherDir1
|  |-File1
|  |-File2
|  |-File2
|-OtherDir2
   |-File1
   |-File2

In this example we should NOT move SomeTopDir1 because it contains a file with the string "inprogress". We should however move SomeTopDir2 because it contains no such file. In short, "inprogress" means leave it alone.

Executing this in PowerShell is quite easy. CMD is a pain, and I'll skip that crazy long command because it is a circus trick. Here is the command to do exactly what Carlos asked:

PS C:\jobsdir> Get-ChildItem | ? { $_.PSIsContainer } | 
    ? { -not ( Get-ChildItem $_ -Recurse -Filter *.inprogress ) } | 
    Move-Item -Destination \archive
 

This command with use Get-ChildItem to list the contents of the current directory. We first filter for Directories (Container objects) just in case there are files in the root of the directory that we don't want to move. Next, another Where-Object cmdlet (alias ?) is used to check all the sub-directories and look for a file matching "*.inprogress". The -Not operator inverts the match so that only directores with a "*.inprogress" file will be passed down the pipeline.

At this point we have the directories that do not contain this file. The results are then piped into Move-Item and the directories are moved to the \archive directory.

One of the other criteria that Mr. IHaveNoLastName requested is that the command must work on XP. Well it does, but only if you install PowerShell. Sadly, XP does not support PowerShell v3. With PowerShell v3's simplified syntax (and some additional aliases) we can shorten the command to this:

PS C:\jobsdir> ls | ? PSIsContainer | ? { -not ( ls $_ -r -fi *.inprogress ) } | 
    mv -d \archive
 

Thanks for an easy one Carlos! Hal, your turn. I suspect this is will be almost as easy for you (even though it won't work on XP).

Hal takes it easy

This one's quite do-able in the shell. But unlike Tim's solution, the most straightforward approach in Linux is a loop:

for i in *; do [ "$(find $i -type f -name \*.inprogress)" ] || mv $i /some/dest; done

The loop is over all of the directories in the current directory. Inside the loop we run a find command looking for "*.inprogress" files. If we find any, then the test operator ("[ ... ]") returns true and we don't do the mv command on the other side of the "||". If we find nothing, then the directory gets moved. Easy peasy

"But wait!", I hear you cry, "That was too easy. And besides, you're running a mv command for each individual directory!"

OK, fine. You want a single mv command? Here you go:

mv $(ls | grep -vf <(find * -type f -name \*.inprogress | cut -f1 -d/)) /some/dest

Happy now?

The best way to puzzle this one out is to start with the command in the innermost parentheses:

find * -type f -name \*.inprogress | cut -f1 -d/

The find command returns the pathnames of all of the *.inprogress files, and the cut command pulls off the top-level directory name. If there are multiple *.inprogress files in a single directory, we'll get multiple instances of the top-level directory name, but that doesn't really matter.

The "<( ... )" syntax takes the output of our find pipeline and lets it be treated as an input file for another command:

ls | grep -vf <( ... )

We take the output of ls and use "grep -v" to filter out directories we don't want. Normally "grep -f" takes a list of patterns from an input file, but in this case we use the "<( ... )" syntax to substitute our find output instead of a normal input file. So we suppress any directories that have a *.inprogress file in them. Anything left over is a directory without a *.inprogress file, which is precisely the set of directories we want to move.

So we wrap the complicated ls pipline up in "$(...)" so that the output-- the list of directories we want to move-- is substituted into the "mv $(...) /some/dest" command. And that gets us to where we want to be.

Or you could use the same idea, but with xargs:

ls | grep -vf <(find * -type f -name \*.inprogress | cut -f1 -d/) | xargs mv -d /some/dest

This looks a lot more like Tim's approach in Powershell. However, this makes use of the "mv -d /some/dest ..." syntax that's supported in the GNU version of the command, but not widely supported in other more traditional Unix distros.

Oh, and by the way, Tim, this all works fine under Windows XP if you'd just install Cygwin like I've been telling you to...

Update:

m_cnd wrote in again with a shortcut for CMD.EXE:

dir SomeTopDir /s /b | findstr /i /e ".extension" > nul || move SomeTopDir Destination

I use this trick all the time, so I feel bad that I missed it here. With his shortcut I can put together a For loop (our favorite, and only, text parser) to do the work.

C:\> for /F "tokens=*" %i in ('dir /b /AD') do dir "%i" /s /b /a-d "%i\*.extension" 1>nul 2>nul || move "%i" Destination

The Dir command with /AD will list directories and not files. We can then use the output to search and move if necessary. The Tokens and quotes are used in case the directory names contain spaces.

Thanks again m_cnd!