0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Mac の zsh 設定を Windows PowerShell に移植したら、罠が5つあった

0
Last updated at Posted at 2026-07-31

Mac で育てた .zshrc を、Windows でも使いたい

長年 Mac で開発していて、.zshrc にはプロンプトのカスタマイズや git 連携、エイリアスがそれなりに溜まっていました。ところがメインの開発環境が Windows になり、素の PowerShell と向き合うことに。

黒い画面に PS C:\Users\...> とだけ出るプロンプト。ブランチも分からない、ll も無い、vi キーバインドも効かない——これは辛い。ということで、zsh の設定を PowerShell のプロファイルに移植してみました。

結論から言うと、大半の設定には等価な仕組みがあり、ほぼ同じ使い勝手を再現できました。ただし Windows ならではの罠が5つあったので、そこを中心に書いていきます(うち1つは、完成した設定を2台目の PC に展開したときに初めて踏みました)。

表示はこんな感じになっています。
image.png

設定ファイルはここにあるます。

前提環境

項目 内容
OS Windows 11
シェル Windows PowerShell 5.1(PowerShell 7 ではありません)
PSReadLine 5.1 同梱の 2.0.0
ターミナル Zed / WezTerm

シェルのバージョンをわざわざ太字にしたのには理由があります。Windows PowerShell 5.1 と PowerShell 7 は別物で、&& が使えない・既定の文字エンコーディングが違うなど、挙動の差がかなりあります。この記事の内容は 5.1 前提です(7 ならもっと素直に書ける箇所もあります)。

移植元の .zshrc(抜粋)

Mac で使っていた設定のうち、今回移植した部分はこのあたりです。

# vi キーバインド
bindkey -v

# ディレクトリ名だけで cd
setopt auto_cd

# 履歴
HISTSIZE=1000000
setopt hist_ignore_all_dups
setopt hist_ignore_space
setopt inc_append_history
setopt share_history

# git ブランチと状態を色付きでプロンプト表示
autoload -Uz vcs_info
function rprompt-git-current-branch {
  # git status の結果に応じて clean / untracked / to be staged ... を色分け
}
PROMPT='%{${fg[black]}%}%~%{${reset_color}%}
%{${fg[green]}%}[%n]%{${reset_color}%}%{${fg[blue]}%}$vcs_info_msg_0_%{${reset_color}%}$ '

# プラグイン
zplug 'zsh-users/zsh-syntax-highlighting'
zplug 'zsh-users/zsh-autosuggestions'

設定ファイルはどこに置くのか

PowerShell の「.zshrc に相当するファイル」は プロファイル と呼ばれ、パスは自動変数 $PROFILE で確認できます。

PS> $PROFILE

……ここで、いきなり罠を踏みました(詳細は後述の「罠①」)。私の環境では $PROFILEDocuments フォルダではなく OneDrive 配下を指していたのです。

最終的な構成はこうしました。dotfiles リポジトリで git 管理したいので、実体は dotfiles に置き、$PROFILE の位置には「読み込むだけのローダー」を置く方式です。

$PROFILE の場所(環境によっては OneDrive 配下)
└── Microsoft.PowerShell_profile.ps1     ← ローダー(2行だけ・編集しない)
                    │
                    ↓ 読み込み
~\dotfiles\powershell\
└── Microsoft.PowerShell_profile.ps1     ← 実体(編集するのはこちら)

ローダーの中身は2行だけです。

$dotfilesProfile = Join-Path $env:USERPROFILE 'dotfiles\powershell\Microsoft.PowerShell_profile.ps1'
if (Test-Path -LiteralPath $dotfilesProfile) { . $dotfilesProfile }

「ジャンクション(シンボリックリンク的なもの)でいいのでは?」と思うかもしれませんが、それも罠①で説明します。

なお、$PROFILE が指すフォルダは自動では作られません。まっさらな PC だと WindowsPowerShell フォルダごと存在しないので、ローダーを置く前に掘っておく必要があります。

$dir = Split-Path $PROFILE
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }

どうやって適用するのか

新しくターミナルを開けば自動で読み込まれます。開いているセッションに反映したいときは、zsh の source ~/.zshrc に相当するこれだけです。

. $PROFILE

「プロファイルを編集 → . $PROFILE → 表示を確認」のループで育てていきます。

設定の中身

ここからは移植したプロファイルの中身を、zsh との対応付きでセクションごとに見ていきます。

文字コードを UTF-8 に

# zsh: export LANG='ja_JP.UTF-8'
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false

auto_cd(ディレクトリ名だけで移動)

zsh の setopt auto_cd は、PowerShell では「コマンドが見つからなかったときのフック」で再現できます。入力された文字列が実在するディレクトリなら Set-Location に差し替える、という仕掛けです。

# zsh: setopt auto_cd
$ExecutionContext.InvokeCommand.CommandNotFoundAction = {
    param($CommandName, $EventArgs)
    if (Test-Path -LiteralPath $CommandName -PathType Container) {
        $EventArgs.CommandScriptBlock = { Set-Location -LiteralPath $CommandName }.GetNewClosure()
        $EventArgs.StopSearch = $true
    }
}

PSReadLine(キーバインド・履歴・補完)

zsh でプラグインに頼っていた部分は、ほぼ PSReadLine の標準機能でまかなえました。

if (Get-Module -Name PSReadLine) {

    # zsh: HISTSIZE / hist_ignore_all_dups / inc_append_history
    Set-PSReadLineOption -MaximumHistoryCount 100000
    Set-PSReadLineOption -HistoryNoDuplicates
    Set-PSReadLineOption -HistorySaveStyle SaveIncrementally

    # zsh: hist_ignore_space — 先頭が空白のコマンドは履歴に残さない
    Set-PSReadLineOption -AddToHistoryHandler {
        param($line)
        if ($line -match '^\s') { return $false }
        return $true
    }

    # zsh: bindkey -v
    Set-PSReadLineOption -EditMode Vi
    Set-PSReadLineOption -ViModeIndicator Cursor

    # Tab でメニュー形式の補完
    Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete

    # zsh: bindkey '^Y' vi-forward-word
    Set-PSReadLineKeyHandler -Chord 'Ctrl+y' -Function ForwardWord

    # zsh-autosuggestions 相当(PSReadLine 2.1.0 以降のみ)
    $psrlVersion = (Get-Module PSReadLine | Select-Object -First 1).Version
    if ($psrlVersion -ge [version]'2.1.0') {
        Set-PSReadLineOption -PredictionSource History
    }
}

zsh プラグインとの対応はこうなりました。

zsh PowerShell
zsh-syntax-highlighting PSReadLine 標準(設定不要で色が付く)
zsh-autosuggestions -PredictionSource History(※ 2.1.0 以降)
Ctrl+R の履歴検索(peco/anyframe) PSReadLine 標準の Ctrl+R

上のコードでバージョン判定を挟んでいるのは、未更新の環境でもエラーにならないようにするためです。

Tab 補完と入力予測は別物

ここで混同しやすいのですが、MenuComplete(Tab 補完)と PredictionSource(入力予測)は役割の違う機能です。Tab を押せば候補が出るから autosuggestions も効いている、と勘違いしがちなので整理しておきます。

Tab 補完(MenuComplete 入力予測(PredictionSource History
発動 Tab キーを押したとき 何も押さなくても入力中に自動で
候補の出所 コマンド名・パラメータ名・パスをその場で計算 過去に実行したコマンドの履歴
見た目 候補が一覧メニューとして展開される 行末に続きが薄いグレーで表示される
確定 Tab / Enter または EndCtrl+F で1単語ずつ)
zsh でいうと compinit zsh-autosuggestions
2.0.0 で 使える 使えない

Tab 補完(MenuComplete)の画像。TABを押すと右に対象がずれていく。
image.png

入力予測(PredictionSource History)の画像。赤枠の部分です。
image.png

PSReadLine の更新(5.1 では一手間かかる)

Windows PowerShell 5.1 同梱の PSReadLine は 2.0.0 なので、上の表の右側——薄い色で入力予測が出る autosuggestions 相当——を使うには更新が必要です。ただし 5.1 では 1 行では終わらないことがあります。

# PSGallery は TLS 1.2 必須。5.1 の既定(SystemDefault)のままだと接続に失敗することがある
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Install-Module PSReadLine -Scope CurrentUser -Force

初回は NuGet パッケージプロバイダーの導入確認と、「信頼されていないリポジトリ」の確認プロンプトが出ます(PSGallery は既定で Untrusted)。非対話のスクリプトから流すなら Install-PackageProvider -Name NuGet -Scope CurrentUser -Force を先に打っておくと止まりません。

そしてここでも罠①が顔を出します-Scope CurrentUser のインストール先は Documents\WindowsPowerShell\Modules\ なので、Documents が OneDrive にリダイレクトされている環境ではモジュール本体まで OneDrive の同期対象になります。プロファイルをローダー方式にして OneDrive を避けても、モジュールは結局そこに入るわけです(数 MB なので実害は小さく、私はそのままにしました)。

なお同梱の 2.0.0 は削除も上書きもされません。C:\Program Files\WindowsPowerShell\Modules\ にそのまま残り、PSModulePath の優先順位でユーザー領域の新しい方が使われるだけです。戻したいときは Uninstall-Module PSReadLine -Scope CurrentUser で 2.0.0 に戻ります。

もう一点、入力予測は仮想ターミナル処理に対応したコンソールを要求します。出力がリダイレクトされた状態でプロファイルを読み込むと、この行だけエラーになります。

Set-PSReadLineOption : The predictive suggestion feature cannot be enabled because
the console output doesn't support virtual terminal processing or it's redirected.

プロファイルの動作をスクリプト経由で検証しているときに出やすいものです。設定が間違っているわけではないので、普通にターミナルを開き直せば効きます。

git 連携プロンプト

今回の移植で一番こだわった部分です。zsh 時代のプロンプトはこういう表示でした。

~/dev/my-project
[<ユーザー名>][develop] clean$

1行目にカレントディレクトリ、2行目にユーザー名・ブランチ・作業ツリーの状態。この「状態」は git status の結果に応じて色と文言が変わります。

表示 意味
clean シアン 変更なし。すべてコミット済み
untracked git 管理外の新規ファイルがある
to be staged マゼンタ 変更はあるが git add していない
to be committed git add 済みだが未コミット
!(no branch) リベース進行中

image.png

PowerShell では prompt という名前の関数を定義すると、それがプロンプトになります。まず git の状態判定から。

# git の有無は起動時に1度だけ判定(プロンプトで毎回 Get-Command しないため)
$script:HasGit = [bool](Get-Command git -ErrorAction SilentlyContinue)

function script:Get-GitPromptStatus {
    if (-not $script:HasGit) { return $null }

    $inside = & git rev-parse --is-inside-work-tree 2>$null
    if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') { return $null }

    $branch = & git rev-parse --abbrev-ref HEAD 2>$null
    $gitDir = & git rev-parse --git-dir 2>$null

    # リベース中の判定
    if ($gitDir) {
        $rebaseMerge = Join-Path $gitDir 'rebase-merge'
        $rebaseApply = Join-Path $gitDir 'rebase-apply'
        if ((Test-Path -LiteralPath $rebaseMerge) -or (Test-Path -LiteralPath $rebaseApply)) {
            return @{ Branch = $branch; Label = '!(no branch)'; Color = 31 }
        }
    }

    $porcelain = @(& git status --porcelain 2>$null)

    if ($porcelain.Count -eq 0) {
        return @{ Branch = $branch; Label = 'clean'; Color = 36 }
    }
    if ($porcelain | Where-Object { $_ -like '??*' }) {
        return @{ Branch = $branch; Label = 'untracked'; Color = 31 }
    }
    if ($porcelain | Where-Object { $_.Length -ge 2 -and $_[1] -ne ' ' }) {
        return @{ Branch = $branch; Label = 'to be staged'; Color = 35 }
    }
    if ($porcelain | Where-Object { $_.Length -ge 1 -and $_[0] -ne ' ' }) {
        return @{ Branch = $branch; Label = 'to be committed'; Color = 33 }
    }
    return @{ Branch = $branch; Label = ''; Color = 34 }
}

元の zsh 版は git status人間向け出力grep していましたが、移植にあたり --porcelain(機械可読な短い形式)でのパースに変えました。プロンプトは表示のたびに実行されるので、git の呼び出しはなるべく軽く・少なくしたいからです。

そして prompt 関数本体。

function prompt {
    # プロンプト内の git 呼び出しで $LASTEXITCODE が壊れるのを防ぐ
    $lastExit = $LASTEXITCODE

    $esc   = [char]27
    $reset = "$esc[0m"

    # 1行目: カレントディレクトリ (zsh: %~)
    $path = $PWD.Path.Replace($HOME, '~')
    $line1 = "$esc[36m$path$reset"

    # 2行目: [ユーザー名] (zsh: %n)
    $line2 = "$esc[32m[$env:USERNAME]$reset"

    $git = Get-GitPromptStatus
    if ($null -ne $git) {
        $line2 += "$esc[34m[$($git.Branch)]$reset"
        if ($git.Label) {
            $line2 += " $esc[$($git.Color)m$($git.Label)$reset"
        }
    }

    $global:LASTEXITCODE = $lastExit
    return "$line1`n$line2`$ "
}

地味に重要なのが冒頭と末尾の $LASTEXITCODE の退避・復元です。プロンプト内で git を実行すると、直前に自分が実行したコマンドの終了コードが git のもので上書きされてしまうため、保存して戻しています。これをやらないと「さっきのコマンド、失敗した?」の確認ができなくなります。

なお zsh には右端に表示する RPROMPT があり、状態表示はそちらに出していましたが、PowerShell に右プロンプトの仕組みは無いので左側に併記する形にしました。

エイリアス

Set-Alias は引数を持てない(罠③)

zsh なら alias la='ls -a' で済む話が、PowerShell ではそうはいきません。Set-Alias は「名前 → コマンド」の対応しか作れず、引数を含められないのです。そのため引数入りのものは関数として定義します。

function ll { Get-ChildItem @args }          # 詳細表示           (Unix: ls -l)
function la { Get-ChildItem -Force @args }   # 隠しファイルも表示 (Unix: ls -a)

# 引数なしの単純な別名なら Set-Alias でよい
Set-Alias c Set-Location                     # cd を1文字で

@args を付けておくと、ll C:\path のように引数もそのまま渡せます。

ls 風の複数列表示(罠④)

l はファイル名を複数列で出し、ディレクトリには / を付けて色分けする関数にしました。Unix の ls -F 相当です。

image.png

これが一筋縄ではいきませんでした。PowerShell は既定で「1オブジェクト=1行」で出力するため、何もしないと縦に長いリストになります。Format-Wide で列に詰められる……のですが、2つ落とし穴があります。

  1. プレーンな文字列は列にならない。 Get-ChildItem -Name の返す文字列をパイプしても1列のまま。プロパティを持つオブジェクトを渡す必要があります
  2. ANSI エスケープで色を付けると列がずれる。 Format-Wide はエスケープシーケンスも文字数として数えるため、色付き文字列を混ぜると桁が揃いません

結局、端末幅から列数を自前で計算し、Write-Host -ForegroundColor で色を付ける実装に落ち着きました。

function l {
    $items = @(Get-ChildItem @args)
    if ($items.Count -eq 0) { return }

    # ディレクトリは末尾に / を付けてシアン、ファイルは既定色
    $entries = foreach ($item in $items) {
        if ($item.PSIsContainer) {
            [PSCustomObject]@{ Text = $item.Name + '/'; Color = 'Cyan' }
        } else {
            [PSCustomObject]@{ Text = $item.Name;       Color = $null }
        }
    }

    # 端末幅から列数を算出
    $consoleWidth = 80
    try { $consoleWidth = $Host.UI.RawUI.WindowSize.Width } catch { }
    if (-not $consoleWidth -or $consoleWidth -lt 20) { $consoleWidth = 80 }

    $maxLen = 0
    foreach ($e in $entries) { if ($e.Text.Length -gt $maxLen) { $maxLen = $e.Text.Length } }
    $colWidth = $maxLen + 2
    $cols = [int][Math]::Max(1, [Math]::Floor($consoleWidth / $colWidth))

    for ($i = 0; $i -lt $entries.Count; $i++) {
        $e = $entries[$i]
        $pad = $e.Text.PadRight($colWidth)
        if ($e.Color) {
            Write-Host -Object $pad -ForegroundColor $e.Color -NoNewline
        } else {
            Write-Host -Object $pad -NoNewline
        }
        if ((($i + 1) % $cols) -eq 0) { Write-Host '' }
    }
    if (($entries.Count % $cols) -ne 0) { Write-Host '' }
}

色はどこで決まっているのか

このプロファイルで「色」を指定している場所は2つあります。

  1. プロンプト — ANSI エスケープシーケンス(ESC[36m = シアン、など)で色番号を指定
  2. l のディレクトリ色分けWrite-Host -ForegroundColor Cyan

ここで大事なのは、どちらも指定しているのは「色番号」であって、実際に画面に出る色はターミナル側のカラーテーマが決めているという点です。ANSI の 36 番(シアン)が実際にどんな色味で描画されるかは、ターミナルのテーマ設定の cyan に割り当てた色値次第。つまりターミナルのテーマを変えると、プロンプトも l の色も一緒に変わります。プロファイル側で色をハードコードしていないぶん、テーマの雰囲気に自然に馴染んでくれます。

分かりにくいかもですが、比較画像です。

image.png

image.png

ハマりどころまとめ

最後に、今回踏んだ罠を整理します。

罠①:$PROFILE が OneDrive を指している

Documents\WindowsPowerShell\ にプロファイルを置いたのに読み込まれない——という現象に遭遇しました。原因は Documents フォルダの OneDrive リダイレクト。OneDrive のバックアップ機能が有効な環境では、$PROFILE はこうなります。

C:\Users\<ユーザー名>\OneDrive\ドキュメント\WindowsPowerShell\Microsoft.PowerShell_profile.ps1

C:\Users\<ユーザー名>\Documents\... に置いたファイルは一切参照されません$PROFILE を確認せず「Documents に置けばいい」という古い知識で作業したのが敗因でした。

さらに、dotfiles で管理するために「$PROFILE の場所からリポジトリへジャンクションを張る」ことも考えましたが、これはやめました。OneDrive がジャンクションを辿って、リポジトリ全体(.git 込み)をクラウドに同期しようとするためです。前述のローダー方式なら、OneDrive 側に置かれるのは2行のテキストファイル1つだけで済みます。

罠②:BOM 無し UTF-8 で保存すると壊れる

プロファイルに日本語コメントを書いて保存したところ、読み込み時に大量の構文エラーが出ました。

Unexpected token '}' in expression or statement.

原因は文字エンコーディングです。Windows PowerShell 5.1 は、BOM の無いファイルを Shift-JIS(システムの ANSI コードページ)として読みます。BOM 無し UTF-8 で保存された日本語コメントは化けて、その過程で後続の行を巻き込み、{ } の対応が壊れるのです。

対処は「BOM 付き UTF-8 で保存する」だけ。ただし最近のエディタは既定が BOM 無し UTF-8 なので、これは誰でも踏む罠だと思います。日本語コメントを書くなら保存形式に要注意です(PowerShell 7 は BOM 無し UTF-8 が既定で読めるので、この罠は 5.1 固有です)。

罠③:Set-Alias は引数を持てない

前述の通り。alias la='ls -a' の感覚で Set-Alias la 'Get-ChildItem -Force' と書いても動きません。引数入りは関数で定義します。

罠④:Format-Wide の落とし穴

前述の通り。プレーンな文字列は複数列にならず、ANSI 色を混ぜると列がずれます。色付き複数列をやるなら自前レイアウトが必要です。

罠⑤:実行ポリシーが Restricted だとプロファイルは1行も実行されない

これは移植作業そのものではなく、完成した設定を2台目の PC に展開したときに踏みました。プロファイルを正しい場所に置き、BOM 付き UTF-8 で保存し、ローダーも用意した。なのにターミナルを開くと赤いエラーが出るだけで、プロンプトは PS C:\...> のまま。

. : File C:\Users\<ユーザー名>\...\Microsoft.PowerShell_profile.ps1 cannot be loaded
because running scripts is disabled on this system.

Windows クライアント版の Windows PowerShell 5.1 は、実行ポリシーの既定が Restricted.ps1 を一切実行しない)です。プロファイルも .ps1 である以上、当然読み込まれません。1台目では過去のどこかで緩和していたので気づかず、まっさらな PC で初めて表面化しました。

現状は Get-ExecutionPolicy -List で確認できます。

PS> Get-ExecutionPolicy -List

        Scope ExecutionPolicy
        ----- ---------------
MachinePolicy       Undefined
   UserPolicy       Undefined
      Process       Undefined
  CurrentUser       Undefined
 LocalMachine       Undefined

全部 Undefined なら実効値は既定の Restricted です。対処はこれだけ。

Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

RemoteSigned は「ローカルで作った .ps1 は実行可、インターネット由来(MOTW 付き)のものは署名が無ければ拒否」という設定です。CurrentUser スコープなので管理者権限は不要で、影響も自分のユーザーだけ。戻したくなったら -ExecutionPolicy Undefined を指定すれば Restricted に戻ります。プロファイルを読ませたいだけなら BypassUnrestricted まで緩める必要はありません。

ひとつ紛らわしいのが、成功しているのにエラーが出るケースです。

Set-ExecutionPolicy : Windows PowerShell updated your execution policy successfully,
but the setting is overridden by a policy defined at a more specific scope.

実行ポリシーは MachinePolicy > UserPolicy > Process > CurrentUser > LocalMachine の優先順位を持ち、より上位のスコープに設定があるとそちらが勝ちます。上のメッセージは「CurrentUser への書き込みは成功したが、いまのセッションには Process スコープの設定が効いているため実効値は変わらない」という意味です。ターミナルを -ExecutionPolicy Bypass 付きで起動するツール経由で作業しているとこうなります。Get-ExecutionPolicy -ListCurrentUser の行が RemoteSigned になっていれば書き込みは成功しているので、新しくターミナルを開き直せば意図通りに効きます。

zsh → PowerShell 対応表

zsh の設定 PowerShell での移植先 移植度
bindkey -v Set-PSReadLineOption -EditMode Vi
HISTSIZE / hist_* Set-PSReadLineOption の履歴系オプション
setopt auto_cd CommandNotFoundAction フック
プロンプト(PROMPT / vcs_info prompt 関数 + git コマンド
RPROMPT(右プロンプト) 仕組みが無いので左に併記
zsh-syntax-highlighting PSReadLine 標準
zsh-autosuggestions -PredictionSource History(要 2.1.0+)
peco / anyframe(履歴検索) PSReadLine 標準の Ctrl+R
alias Set-Alias(引数なし)/ 関数(引数あり)
Homebrew / pyenv / LESS macOS 固有のため移植せず

まとめ

  • PowerShell の .zshrc 相当は $PROFILE。ただしその場所が OneDrive かもしれないので、必ず $PROFILE を打って確認してから始める
  • zsh プラグインで実現していたことの多くは PSReadLine の標準機能でまかなえる
  • git 連携プロンプトも prompt 関数で再現できる。--porcelain でのパースと $LASTEXITCODE の退避がポイント
  • 日本語コメントを書くなら BOM 付き UTF-8。これだけは覚えて帰ってください
  • 別の PC に展開するときは実行ポリシーを確認する。既定の Restricted のままだと、ファイルをどれだけ正しく置いてもプロファイルは1行も実行されない

「Windows のターミナルは味気ない」と思っていましたが、プロファイルを育てれば zsh 時代の使い勝手にかなり近づけられました。同じように Mac から Windows に移ってきた方の参考になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?