複数のプロジェクトで同じ設定ファイルを使い回したい、容量の大きいキャッシュを別の場所へ逃がしたい。開発をしていると、こういう場面でシンボリックリンクが欲しくなる。LinuxやmacOSならln -sの1行で済む話だが、Windowsではリンクの種類が3つあり、作れるかどうかが権限や設定で変わり、消し方を間違えると元のファイルまで消える。毎回なんとなく調べ直しているので、ここで一度まとめておく。
確かめた環境は、手元のWindows 11(ビルド26300)、標準のWindows PowerShell 5.1(5.1.26100)、Git for Windows 2.55、Node.js 24.19だ。一時フォルダの中に作業用のフォルダを作り、リンクを作る、フォルダごと動かす、消す、Gitで取り出す、という操作を順に試した。結果は最後に表にしている。
Windowsのリンクは3種類、まず何が違うのか

WindowsのNTFSで使えるリンクは、シンボリックリンク、ジャンクション、ハードリンクの3つだ。コマンドプロンプトのmklinkで、オプションを変えるとそれぞれを作り分けられる。何も付けなければファイルのシンボリックリンク、/Dでフォルダのシンボリックリンク、/Jでジャンクション、/Hでハードリンクになる。
シンボリックリンクは、いちばんln -sに近いものだ。ファイルにもフォルダにも使え、行き先を相対パスで書ける。ネットワーク上の共有フォルダを指すこともできる。Windows Vistaから使えるようになった。
ジャンクションは、それより前のWindows 2000の時代からある、フォルダ専用のリンクだ。行き先は同じパソコンの中のドライブに限られ、パスは絶対パスで保存される。権限の面ではいちばん気軽で、一般ユーザーのまま作れる。
ハードリンクは性質がまったく違う。1つのファイルの実体に、名前を2つ付けるしくみで、どちらが本物という区別がない。ファイル専用で、同じボリュームの中でしか作れない。手元でハードリンクの側に1行書き足すと、元のファイルの中身も同じように増えていた。片方の名前を消しても、もう片方の名前が残っていればファイルの中身は消えない。
dirで一覧を出すと、この違いがそのまま見える。シンボリックリンクは<SYMLINK>か<SYMLINKD>、ジャンクションは<JUNCTION>と表示され、角かっこの中に行き先が出る。ハードリンクは普通のファイルと見分けがつかない。
管理者権限が要るのはシンボリックリンクだけ、開発者モードで外せる

シンボリックリンクを作るには、本来は「シンボリックリンクの作成」という特権が必要で、既定では管理者にしか与えられていない。ジャンクションとハードリンクにはこの制限が無い。開発ツールがフォルダのリンクにジャンクションを好んで使うのは、権限で失敗しないからだと思う。
この制限は、Windows 10のCreators Update(バージョン1703)から緩くなった。設定の「開発者向け」で開発者モードをオンにしておくと、一般ユーザーでもシンボリックリンクを作れる。ただし作る側のプログラムが、API(CreateSymbolicLink)に「特権なしでの作成を許可する」という専用の値(SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE)を渡している場合に限られる。開発者モードをオンにしただけで、どのツールでも作れるようになるわけではない。
手元のパソコンは開発者モードがオンで、作業したユーザーは管理者ではない。この状態で4種類を作り比べた結果が次の表だ。
| 作り方 | 種類 | 結果 |
|---|---|---|
mklink / mklink /D | シンボリックリンク | 作成できた |
mklink /J / mklink /H | ジャンクション・ハードリンク | 作成できた |
PowerShell 5.1のNew-Item -ItemType SymbolicLink | シンボリックリンク | 「この操作には管理者特権が必要です」で失敗 |
PowerShell 5.1のNew-Item -ItemType Junction / HardLink | ジャンクション・ハードリンク | 作成できた |
Node.jsのfs.symlinkSync(dir・junction・ファイル) | 3種とも | 作成できた |
意外だったのはPowerShell 5.1だ。同じウィンドウのmklinkは通るのに、New-Itemでシンボリックリンクを作ろうとすると、開発者モードがオンでも管理者権限を求められて止まった。5.1しか無い環境でスクリプトからシンボリックリンクを作るなら、cmd /c mklinkを呼ぶのがいちばん確実だった。PowerShell 7は手元に入っていないので、こちらは確かめていない。
フォルダごと動かすと、ジャンクションだけが切れた
projというフォルダの中にsharedというフォルダを置き、同じprojの中に、sharedを指すシンボリックリンクとジャンクションを1つずつ作った。どちらもmklinkには相対パスのsharedを渡している。そのあとprojをproj2に名前を変えて、リンクの先にあるファイルが読めるかを見た。
シンボリックリンクのほうは問題なく読めた。dirの表示も[shared]のままで、相対パスがそのまま保存されている。一方のジャンクションは読めなくなっていた。表示を見ると、行き先はC:\Users\…\proj\sharedという絶対パスになっている。相対パスで指定しても、作った時点で絶対パスに直して保存されるので、元のフォルダの名前が変わると行き先を見失う。
プロジェクトの中で完結するリンク、たとえばリポジトリの中で共通の設定ファイルを指すようなものは、相対パスのシンボリックリンクにしておくと、フォルダを移動しても、ほかの人の環境に置いても動く。逆に、C:\cacheのような決まった場所を指すなら、ジャンクションの絶対パスでも困らない。
場所の制約もある。ハードリンクは同じボリュームの中だけ、ジャンクションは同じパソコンのドライブの間だけ、ネットワーク上の共有フォルダを指せるのはシンボリックリンクだけだ。ただしネットワークの扱いは設定で制限されている。fsutil behavior query SymlinkEvaluationで確かめると、手元では「ローカルからローカル」と「ローカルからリモート」が有効、「リモートからローカル」と「リモートからリモート」が無効になっていた。共有フォルダの中に置かれたシンボリックリンクは、既定では手元からたどれないということだ。
消すときはrmdir、delはリンク先の中身を消す

いちばん気をつけたいのがここだ。フォルダを指すリンクを消すときに、コマンドを間違えると、リンクではなくリンク先のファイルが消える。中身の入ったフォルダを4つ用意し、それぞれにリンクを張ってから、消し方を変えて試した。
rmdirでフォルダのシンボリックリンクを消すと、リンクだけが消え、リンク先のファイルは残った。これが正しい消し方だ。ところが、ジャンクションに対してdel /qを実行すると、ジャンクションそのものは残ったまま、リンク先のフォルダにあったファイルが消えた。delは「このフォルダの中のファイルを消す」という命令として受け取り、リンクをたどった先で実行してしまう。確認の/qを付けていなければ「よろしいですか」と聞かれるが、リンクだと気づかずに「はい」と答えれば同じことになる。
PowerShell 5.1のRemove-Itemは、手元の版では-Recurse -Forceを付けてジャンクションを消しても、リンクだけが消えてリンク先のファイルは残った。ただ-Recurseを付けないと削除してよいかの確認を求めてきて、無人で動かすスクリプトではそこで止まる。PowerShellは版によってこのあたりの挙動が変わってきた経緯もあるので、スクリプトでリンクを消すときは、cmd /c rmdir <リンク名>を呼ぶのが、どの版でも同じ結果になって安心できると感じた。ファイルのシンボリックリンクはdelで消してよく、こちらはリンク先のファイルは消えない。
コピーやバックアップにも似た注意が要る。リンクをたどってしまう道具では、同じ中身が二重にコピーされたり、リンクが親フォルダを指していると果てしなく潜っていったりする。robocopyには、ジャンクションをたどらないための/XJというオプションがあるので、リンクを含むフォルダを丸ごと複製するときは付けておく。
Gitでシンボリックリンクを扱うときの設定
リポジトリにシンボリックリンクを入れる場合は、Git側の設定も見ておく。Gitはリンクを、モード120000の特別なファイルとして、中身に行き先のパスを書いた形で記録する。Windowsでこれを本物のリンクとして取り出すかどうかを決めるのがcore.symlinksという設定だ。
手元のGit for Windowsでは、システム全体の設定でcore.symlinksがfalseになっていた。試しに、ファイルのシンボリックリンクを1つ含むリポジトリを作り、falseとtrueでそれぞれ複製した。falseのほうでは、リンクだったはずのファイルが、real.txtという行き先の文字列だけが書かれた8バイトのただのテキストファイルになっていた。trueのほうでは、dirで<SYMLINK>と表示される本物のリンクとして取り出せた。開発者モードがオンなので、管理者権限は要らなかった。
困るのはfalseのときで、エラーも警告も出ないまま、中身が行き先のパスだけのファイルができあがる。設定ファイルを読みにいったツールが、意味の分からない1行を読んで失敗する、という形で初めて気づくことになる。シンボリックリンクを含むリポジトリを扱うなら、開発者モードをオンにしたうえでgit config --global core.symlinks trueにしておくか、複製するときにgit -c core.symlinks=true clone <URL>と指定する。すでに取り出した作業ツリーは、設定を変えただけでは置き換わらないので、該当のファイルを消してからgit checkout -- <パス>で取り出し直す。
開発で使う場面と、どの種類を選ぶか
ここまでの結果から、自分の使い方を整理しておく。
ひとつ目は、複数のリポジトリやフォルダで同じ設定ファイルを共有する場面だ。エディタの設定、リンターの設定などがこれに当たる。ファイルを指すのでジャンクションは使えず、移動にも強いほうがいいので、相対パスのシンボリックリンクにする。開発者モードが前提になるので、チームで使うなら、その旨をREADMEに書いておく。
ふたつ目は、容量の大きいフォルダを別のドライブへ逃がす場面だ。音源のサンプルライブラリや、パッケージのキャッシュなど、アプリが決まった場所を前提にしているフォルダを外付けのドライブへ移し、元の場所にリンクを置く。行き先は固定で、権限の問題も起きないので、ジャンクションが向いている。
みっつ目は、自分で作ったライブラリを、別のプロジェクトから開発中のまま参照する場面だ。Node.jsなどのツールに任せる場合は、ツールが種類を選んでくれる。手元でfs.symlinkSyncを試したかぎり、junctionを指定すれば権限に関係なくフォルダのリンクを作れるので、自作のスクリプトでも、フォルダならジャンクションを選んでおくと、開発者モードがオフの環境でも動く。
| 使いたいこと | 選ぶ種類 | 作り方 | 消し方 |
|---|---|---|---|
| 共通の設定ファイルを複数の場所で使う | シンボリックリンク(相対パス) | mklink <リンク> <行き先> | del |
| フォルダを移動しても切れないリンク | シンボリックリンク(相対パス) | mklink /D <リンク> <行き先> | rmdir |
| 大きなフォルダを別ドライブへ逃がす | ジャンクション | mklink /J <リンク> <絶対パス> | rmdir |
| 同じボリュームで、名前を2つ持たせる | ハードリンク | mklink /H <リンク> <行き先> | del |
| ネットワーク上の共有フォルダを指す | シンボリックリンク | mklink /D <リンク> \\サーバー\共有 | rmdir |
どの種類でも、フォルダを指すリンクはrmdirで消す。これだけは忘れないようにしたい。リンクを張ったフォルダでdelを打ちそうになったら、先にdirで<JUNCTION>や<SYMLINKD>の表示が無いかを確かめる、という習慣をつけておくと、リンク先のファイルを失わずに済む。