読者です 読者をやめる 読者になる 読者になる

tech.guitarrapc.cóm

C#, PowerShell, Unity, Cloud, Serverless Technical Update and Features

PowerShell でディレクトリ構造を保ったまま特定のファイルをコピーする(1)

PowerShell

久々の記事です。更新なくてごめんなさい。

今回は、PowerShell でよく問題になる、ディレクトリ構造を保ったままの特定ファイルのコピーです。

続編について

この記事中の方法は効率面でよろしくないため、事前にコピー対象をフィルタしてから実行するものを用意しました。よろしければこちらもどうぞ

tech.guitarrapc.com

目次

Copy-Item を使ってフォルダを丸ごとコピーする

PowerShell v3 において、Copy-Item を使うことで容易に可能です。

たとえば、c:\valentia というフォルダがこういった構造だとしましょう。

    ディレクトリ: C:\valentia


Mode         LastWriteTime Length Name            
----         ------------- ------ ----            
d---- 2014/08/11     18:59        cs              
d---- 2014/08/11     18:59        example         
d---- 2014/08/11     18:59        functions       
d---- 2014/08/11     18:59        Test            
d---- 2014/08/11     18:59        Tools           
d---- 2014/08/11     18:59        valentia-Help   
-a--- 2014/08/11      2:33   4532 valentia.psd1   
-a--- 2014/08/11      2:33  22031 valentia.psm1   
-a--- 2014/08/11      2:33  15665 valentia.pssproj


    ディレクトリ: C:\valentia\cs


Mode         LastWriteTime Length Name        
----         ------------- ------ ----        
-a--- 2014/08/11      2:33   3815 CredRead.cs 
-a--- 2014/08/11      2:33    670 CredWrite.cs


~~中略~~

    ディレクトリ: C:\valentia\Tools


Mode         LastWriteTime Length Name                    
----         ------------- ------ ----                    
-a--- 2014/08/11      2:33    313 install.bat             
-a--- 2014/08/11      2:33  10129 install.ps1             
-a--- 2014/08/11      2:33   3755 New-valentiaManufest.ps1
-a--- 2014/08/11      2:33   7580 RemoteInstall.ps1       


    ディレクトリ: C:\valentia-Help


Mode         LastWriteTime Length Name             
----         ------------- ------ ----             
-a--- 2014/08/11      2:33    902 valentia.psm1.xml

このフォルダを、まるまる c:\hoge に移動したい場合は、単純です。

Copy-Item に -Recurse スイッチを付けて、対象のパスを指定するだけで完了です。簡単ですね。

Copy-Item -Path C:\valentia -Destination c:\hoge -Recurse

特定のファイルだけコピーする

では、特定のファイルだけ、コピーしてみましょう。

初めに、ディレクトリ構造を維持せずに、そのファイルを指定先にコピーする場合です。

例えば、C:\valentia\Tools\install.bat を c:\hoge にコピーしてみるならこうですか?

Copy-Item -Path C:\valentia\Tools\install.bat -Destination c:\hoge

もし、C:\hoge が存在するフォルダならこれでいいです。

    ディレクトリ: C:\hoge


Mode         LastWriteTime Length Name       
----         ------------- ------ ----       
-a--- 2014/08/11      2:33    313 install.bat

しかし、もし存在しないフォルダだった場合はダメです。 c:\hoge という「ファイル名」でコピーされてしまいます。

f:id:guitarrapc_tech:20140812055748p:plain

ここで、コピー先を c:\hoge\install.bat とフルパスで指定すると、

Copy-Item -Path C:\valentia\Tools\install.bat -Destination c:\hoge\install.bat

c:\hoge がない状態からダメだよと怒られます。

Copy-Item : パス 'C:\hoge\install.bat' の一部が見つかりませんでした。
発生場所 行:1 文字:1
+ Copy-Item -Path C:\valentia\Tools\install.bat -Destination c:\hoge\install.bat
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Copy-Item], DirectoryNotFoundException
    + FullyQualifiedErrorId : System.IO.DirectoryNotFoundException,Microsoft.PowerShell.Commands.CopyItemCommand

つまり、コピー先を保証する必要があるのです。そのため、先にコピー先フォルダを作成しておくのがいいでしょう。

$destinationFolder = "c:\hoge"
New-Item -Path $destinationFolder -ItemType Directory -Force
Copy-Item -Path C:\valentia\Tools\install.bat -Destination c:\hoge

New-Item でのディレクトリ作成は、mkdir でも問題ありません。また、ここで -Force を付けることで、すでに存在していてもエラーを出すことなくディレクトリ生成を保証できます。

うまくコピーできましたね。

f:id:guitarrapc_tech:20140812060308p:plain

複数のファイルをディレクトリ構造を平坦化してコピーする

特定のフォルダにあるファイルを、階層を問わず平坦化してコピーするのも容易です。やる必要があるかは別として。

ようは、コピー元がなんであっても、コピー先が同一フォルダとすればいいのです。つまりこうです。

ls C:\valentia -Recurse | Copy-Item -Destination c:\hoge -Force

あるいは、Foreach-Object で展開しても一緒です。

ls C:\valentia -Recurse | %{Copy-Item -Path .FullName -Destination c:\hoge -Force}

ここで -Force をつけているのは、コピー元がコピー先に平坦化されるため、同一ファイル名/ディレクトリ名のアイテムができてしまうからです。

-Force を付けなければエラーがでます。

f:id:guitarrapc_tech:20140812060727p:plain

一方で、-Force を付ければ、上書きを自動実行します。

f:id:guitarrapc_tech:20140812060940p:plain

コピーされた結果は、まさに平坦です。元のディレクトリ構造を無視して、コピー先にしていしたパスにすべて展開します。もし、ディレクトリ構造を維持してコピーすると思った場合は驚きだと思います。

f:id:guitarrapc_tech:20140812061059p:plain

ディレクトリ構造を維持して特定のファイルをコピーしたい

ではどうやればいいのでしょうか?試行錯誤してみますか?

指定したファイルだけにしたらどうか

つまり、ファイルをあらかじめ絞ってしまうのですね。

ls C:\valentia -Recurse | where Name -eq "install.bat"

結果こうです。

    ディレクトリ: C:\valentia\Tools


Mode         LastWriteTime Length Name       
----         ------------- ------ ----       
-a--- 2014/08/11      2:33    313 install.bat

あとは、コピー元パスとコピー先のフォルダパスを文字列をいじって差し替えて

$directoryName = Join-Path "c:\hoge" &#40$_.DirectoryName -split "c:\\valentia" |select -Last 1&#41

コピー先のps1フルパスを生成します。

Join-Path $directoryName $_.Name

先ほどお伝えした通り、コピー先へのディレクトリ構造を再現しないと起こられるのでそれもやってしまいます。

ということで、コード全文です。

$hoge = ls C:\valentia -Recurse | where {$_.Extension -eq ".ps1"}
$hoge `
| %{
    $directoryName = Join-Path "c:\hoge" &#40$_.DirectoryName -split "c:\\valentia" |select -Last 1&#41
[PSCustomObject]@{
    Path = $_.FullName
    DirectoryName = $directoryName
    Destination = Join-Path $directoryName $_.Name
    }} `
| %{
    New-Item $_.DirectoryName -ItemType Directory -Force
    Copy-Item -Path $_.Path -Destination $_.Destination -Force
}

結果です。うまくいってますね。

    ディレクトリ: C:\hoge


Mode         LastWriteTime Length Name     
----         ------------- ------ ----     
d---- 2014/08/12      6:29        example  
d---- 2014/08/12      6:29        functions
d---- 2014/08/12      6:29        Test     
d---- 2014/08/12      6:29        Tools    


~~中略~~

    ディレクトリ: C:\hoge\Test


Mode         LastWriteTime Length Name      
----         ------------- ------ ----      
d---- 2014/08/12      6:29        Credential


    ディレクトリ: C:\hoge\Test\Credential


Mode         LastWriteTime Length Name             
----         ------------- ------ ----             
-a--- 2014/08/11      2:33    128 Credential.ps1   
-a--- 2014/08/11      2:33     65 Import-Module.ps1
-a--- 2014/08/11      2:33    146 PingAsync.ps1    
-a--- 2014/08/11      2:33    226 Target.ps1       


    ディレクトリ: C:\hoge\Tools


Mode         LastWriteTime Length Name                    
----         ------------- ------ ----                    
-a--- 2014/08/11      2:33  10129 install.ps1             
-a--- 2014/08/11      2:33   3755 New-valentiaManufest.ps1
-a--- 2014/08/11      2:33   7580 RemoteInstall.ps1       

簡単にfunction にしてみましょう。

function Copy-StrictItemWithDirectoryStructure
{
    [cmdletBinding&#40&#41]
    param
    &#40
        [parameter&#40
            Mandatory = 1,
            Position  = 0,
            ValueFromPipeline = 1,
            ValueFromPipelineByPropertyName =1&#41]
        $inputPath,

        [parameter&#40
            Mandatory = 1,
            Position  = 1,
            ValueFromPipelineByPropertyName =1&#41]
        [string[]]
        $Destination,

        [parameter&#40
            Mandatory = 1,
            Position  = 1,
            ValueFromPipelineByPropertyName =1&#41]
        [string]
        $InputRoot
    &#41
    begin
    {
        $root = $InputRoot.Replace&#40"\", "\\"&#41
    }

    process
    {
        $inputPath `
        | %{
            $directoryName = Join-Path $Destination &#40$_.DirectoryName -split $root |select -Last 1&#41
        [PSCustomObject]@{
            Path = $_.FullName
            DirectoryName = $directoryName
            Destination = Join-Path $directoryName $_.Name
            }} `
        | %{
            New-Item $_.DirectoryName -ItemType Directory -Force
            Copy-Item -Path $_.Path -Destination $_.Destination -Force
        }
    }
}

先ほどの例がこうなります。

$hoge = ls C:\valentia -Recurse | where {$_.Extension -eq ".ps1"}
Copy-StrictItemWithDirectoryStructure -inputPath $hoge -Destination c:\hoge -InputRoot c:\valentia

このやり方のメリットは、

  • 初めに必要なファイルをふつーにフィルタリングできる。
  • ディレクトリ構造を複数回なめてない。

デメリットは、

  • コピー元のフィルタを外に出しているので、コピー元が特定できず-InputRoot という感じで渡す必要がある。
  • もとディレクトリ構造をフルネームでテキスト置換をかけててダサすぎる。

まぁできないとかいうのを封じるには容易なやり方なわけですが。

他の方法を考えましょう。

一回コピーしてからいらないものを削除してはどうか

ということで、今回の本題はこれです。

もともとは、とある人が困ってたのでそういえばやったことなかったと思って対応したものです。

コードから見てみましょう。

function Copy-StrictedFilterFileWithDirectoryStructure
{
    [CmdletBinding&#40&#41]
    param
    &#40
        [parameter&#40
            mandatory = 1,
            position  = 0,
            ValueFromPipeline = 1,
            ValueFromPipelineByPropertyName = 1&#41]
        [string]
        $Path,
 
        [parameter&#40
            mandatory = 1,
            position  = 1,
            ValueFromPipelineByPropertyName = 1&#41]
        [string]
        $Destination,
 
        [parameter&#40
            mandatory = 1,
            position  = 2,
            ValueFromPipelineByPropertyName = 1&#41]
        [string[]]
        $Targets,
 
        [parameter&#40
            mandatory = 0,
            position  = 3,
            ValueFromPipelineByPropertyName = 1&#41]
        [string[]]
        $Excludes
    &#41
 
    begin
    {
        $list = New-Object 'System.Collections.Generic.List[String]'
    }
 
    process
    {
        Foreach &#40$target in $Targets&#41
        {
            # Copy "All Directory Structure" and "File" which Extension type is $ex
            Copy-Item -Path $Path -Destination $Destination -Force -Recurse -Filter $target
        }
    }
 
    end
    {
        # Remove -Exclude Item
        Foreach &#40$exclude in $Excludes&#41
        {
            Get-ChildItem -Path $Destination -Recurse -File | where Name -like $exclude | Remove-Item
        }
 
        # search Folder which include file
        $allFolder = Get-ChildItem $Destination -Recurse -Directory
        $containsFile = $allFolder | where {$_.GetFiles&#40&#41}
        $containsFile.FullName `
        | %{
            $fileContains = $_
            $result = $allFolder.FullName `
            | where {$_ -notin $list} `
            | where {
                $shortPath = $_
                $fileContains -like "$shortPath*"
            }
            $result | %{$list.Add&#40$_&#41}
        }
        $folderToKeep = $list | sort -Unique
 
        # Remove All Empty &#40none file exist&#41 folders
        Get-ChildItem -Path $Destination -Recurse -Directory | where fullName -notin $folderToKeep | Remove-Item -Recurse
    }
}

先ほどと大きな違いがあります。

  • フィルタ処理が内部に組まれている
  • フィルタがワイルドカードでファイル名で指定可能 (つまり拡張子なども)
  • いったん必要なフィルタ結果と空ディレクトリをディレクトリ構造ごとコピーしている
  • いらないファイルをExcludeで追加していして削除できる
  • あとからコピー先の空ディレクトリを削除している

何度もディレクトリ構造をなめていますが、こちらは文字列処理を回避しています。膨大な大きさでなければ大きな遅延はないので、まぁ。

では、先ほどの .ps1ファイルのみをコピーしてみましょう。

Copy-StrictedFilterFileWithDirectoryStructure -Path c:\valentia -Destination C:\hoge -Targets *.ps1

簡単ですね。

.md もコピーしてる時に、一部Readme*.md ファイルのみをコピーから除きたい場合でも簡単です。

Copy-StrictedFilterFileWithDirectoryStructure -Path C:\valentia -Destination C:\hoge -Targets *.ps1, *.md -Excludes Readme*.md

個人的にはこちらが好みです。

応用

これを欲しがった人は、リリース用に必要なファイルを集めてzipしたかったようです。

彼のように、開発者にとっても便利に使えればうれしいと思います。

まとめ

もうこれでPowerShell の コピーが辛いとはいわせない?