Windows 上把桌面的 code 目录迁到 F 盘,用目录联接保留原路径
新硬盘装上后,我想把桌面上的 code 目录移到 F:\code。这里有两百多万个文件,除了 Git 仓库,还有 node_modules、构建产物,以及保存 Codex 规则和 Skill 的 codex-home。IDE 的最近项目、Codex 的工作区和一些本地脚本都记着 Desktop\code。如果只把文件搬走,这些入口就得挨个改。
Windows 的目录联接(junction)可以把 Desktop\code 这个目录入口指向同一台电脑上的另一块本地卷。程序打开 Desktop\code\sheng-blog 时,Windows 在解析到 code 这一级时转到 F:\code,再接着查找 sheng-blog。读写都落到 F 盘,原来的项目地址仍然可以用。这是 NTFS 提供的路径解析能力,跨本地卷的示例可以在微软的硬链接与目录联接文档里看到。
第一次复制时,桌面上的目录可以继续用。到切换时,先停掉写入并确认没有进程占用目录,等最后一次比对返回 0,再把 code 这个名字交给联接。
用 Robocopy 复制目录
下面用 Windows 上的 PowerShell 7,桌面的 code 起初是 C 盘上的实体目录,F 盘是本机 NTFS 卷。第一次复制前,确认 F:\code 尚未创建或是专门为这次迁移准备的空目录,盘上也有足够的空间。GetFolderPath 会取 Windows 当前配置的桌面路径,桌面被重定向过时,以命令得到的路径为准。
$desktop = [Environment]::GetFolderPath('DesktopDirectory')
$source = Join-Path $desktop 'code'
$destination = 'F:\code'
$copyOptions = @('/E', '/SJ', '/SL', '/COPY:DAT', '/DCOPY:DAT', '/R:0', '/W:0')
$PSNativeCommandUseErrorActionPreference = $false
robocopy.exe $source $destination @copyOptions
$copyExit = $LASTEXITCODE
$copyExit
/E 包括空目录。/SJ 和 /SL 分别把目录联接、符号链接作为链接复制,避免沿着 node_modules 等目录里的链接把目标内容再复制一遍。原本指向绝对路径的链接仍需检查目标。/COPY:DAT 复制文件数据、属性和时间戳,/DCOPY:DAT 为目录保留相应属性和时间戳。需要保留原目录的 ACL 等安全信息时,要单独选参数并验收权限。参数的完整含义见 Robocopy 文档。
第一次复制返回 1 很正常,表示有文件复制成功。8 及以上才表示发生复制失败,需要先看日志。示例让 PowerShell 按 Robocopy 的退出码处理结果,避免遇到非零值就抛出异常。
退出码 3 表示两边还有哪些差异
切换前保存工作并退出 Codex、IDE、开发服务器和其他会写入仓库的程序。让执行迁移的 PowerShell 离开 code,再用与复制相同的参数加 /L 比对。下面把完整文件名和汇总写进 Unicode 日志。目录大时日志也会比较大,先看末尾的汇总,有差异再查对应条目。
Set-Location -LiteralPath 'F:\'
$compareLog = Join-Path $env:TEMP 'code-migration-compare.log'
robocopy.exe $source $destination /L @copyOptions /FP /NP "/UNILOG:$compareLog"
$compareExit = $LASTEXITCODE
Get-Content -LiteralPath $compareLog -Tail 12
$compareExit
/L 只列出 Robocopy 会做的事。它返回 3 时,1 表示有待复制的条目,2 表示目标侧有额外条目。这里日志的 Copied 是计划复制数。把 3 当作「复制失败」会查错方向,直接放行则会在两边还有差异时切换。其他退出码的组合含义见 Robocopy 的返回值。
首次复制结束时,两边的比对结果是 0。等到准备切换,日志里已经有了两类差异。F 盘多了一个 .git 下的引用记录目录,另有 1,268 个 Git 对象被列为待复制。下面是这次 /L 的汇总节选,省去了路径、字节数和耗时。
Total Copied Skipped Mismatch FAILED Extras
Dirs : 395350 0 395350 0 0 1
Files : 2197641 1268 2196373 0 0 0
目标独有的目录先移到 F:\code 之外单独留存。待复制的文件则从 C 盘补到 F 盘,也就是去掉 /L 再跑复制命令。最终切换脚本也保留了这个补复制分支,补完会再跑一次 /L。如果还有目标独有的条目,逐项确认归属。/MIR 会清除目标独有内容,目标目录里混入其他有用文件时,先把这些文件移出本次迁移范围。
我要求切换前的最后一次比对返回 0。这表示按当前参数,Robocopy 找不到还需要处理的条目。它按文件信息比较,逐文件内容校验还要另算哈希。这两个检查在清理旧副本前都用上了。
目录被占用时,为什么管理员也改不了名
补齐差异后,迁移又在 Rename-Item 上停了一次。终端已经以管理员身份运行,当前目录也是 F:\,报错仍然是 Access to the path ... is denied.。这次失败发生在把旧目录改名的时候,桌面 code 还指向原来的实体目录。
进程打开文件或目录时会指定共享方式。如果它的句柄不允许其他进程删除或改名,提权也不会释放这个句柄。这条规则对应 Win32 的 FILE_SHARE_DELETE。
同样的报错也可能来自权限问题,可以先在资源管理器里对旧目录运行 PowerToys 的 File Locksmith,查看占用进程和对应文件。我的切换脚本用的是它的命令行入口 FileLocksmithCLI.exe --json,传入源目录路径,发现占用就列出进程和文件,暂停切换。
检查时也要算上终端和后台进程。窗口关了以后,终端标签页的工作目录、文件监听器、语言服务器或 Codex 子进程仍可能占着项目。先保存工作,退出查到的进程,再复查并重跑比对。最后一次执行通过了句柄检查,补复制后也成功改了名。
用目录联接接管桌面入口
以下命令接着前面的 PowerShell 会话执行。确认最后一次只读比对为 0,相关进程已退出,桌面的 code 仍是原来的实体目录,且两个临时名称都空着。先用 New-Item -ItemType Junction 创建临时联接,检查它的目标,并从临时路径打开一个已知文件。
if ($compareExit -ne 0) {
throw '最后一次只读比对须返回 0 才能切换。'
}
$sourceItem = Get-Item -LiteralPath $source -ErrorAction Stop
if (!$sourceItem.PSIsContainer -or ($sourceItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw '源路径应是原来的实体目录。'
}
$stagedLink = Join-Path $desktop 'code.link-f'
$backup = Join-Path $desktop 'code.before-f-migration'
if ((Test-Path -LiteralPath $stagedLink) -or (Test-Path -LiteralPath $backup)) {
throw '临时联接或回退目录已存在,请先确认它的用途。'
}
New-Item -ItemType Junction -Path $stagedLink -Target $destination -ErrorAction Stop
Get-Item -LiteralPath $stagedLink | Format-List FullName, LinkType, Target
此时桌面上同时有旧目录和临时联接。确认临时入口读到的是 F 盘文件后,把旧目录改名,再把联接改成 code。两次改名之间旧路径会短暂缺失,因此这一步也要等使用项目的程序退出后执行。第二次改名失败时,旧目录仍然可用来恢复原名。
Rename-Item -LiteralPath $source -NewName 'code.before-f-migration' -ErrorAction Stop
try {
Rename-Item -LiteralPath $stagedLink -NewName 'code' -ErrorAction Stop
} catch {
if (!(Test-Path -LiteralPath $source)) {
Rename-Item -LiteralPath $backup -NewName 'code' -ErrorAction Stop
}
throw
}
Get-Item -LiteralPath $source | Format-List FullName, LinkType, Target
最后一条命令应显示 LinkType 是 Junction,Target 是 F:\code。如果切换后需要回退,先停掉写入,保全切换后写到 F 盘的新改动,再把联接改到临时名称,把 C 盘的回退目录改回 code。
Codex 和 IDE 怎样找到迁移后的项目
IDE 最近项目里记录的 Desktop\code\项目名、Codex 工作区以及普通脚本里的旧项目路径,都会经过桌面联接访问 F 盘。我从原路径跑了 git status 和 pnpm exec hexo version,仓库可读,项目命令也能运行。这个例子里 pnpm 沿用现有锁文件,在 F 盘使用同卷的包存储重新装了依赖。路径能解析,只说明找得到文件,依赖安装是否可用还要实际跑一次项目命令。包存储的跨盘行为可以对照 pnpm 的 storeDir 说明。
Codex 的登录和会话仍由 ~/.codex 管理,工作流资产放在随代码目录一起迁移的 codex-home 仓库里。对照下图的两条线,可以看到 skills 和 AGENTS.md 什么时候会经过桌面入口。
~/.codex/skills 的目录联接保存的目标仍是 Desktop\code\codex-home\skills。Windows 解析到 Desktop\code 时转到 F:\code,再继续查找 codex-home\skills。agents_references 也经由同一个桌面联接找到 F 盘上的目录。
硬链接是同一卷上同一个文件对象的另一个名字。仓库复制到 F 盘后,原来的 ~/.codex/AGENTS.md 还能读,内容也可能和 F 盘那份完全相同,但更新 F 盘文件时,C 盘的硬链接仍然读旧文件。我把这个入口换成文件符号链接,目标写作 Desktop\code\codex-home\global\AGENTS.md。这样它也会随桌面路径解析到 F 盘。工作流资产的布局和链接检查命令见把 Codex Home 从隐藏目录搬出来,用 Git 管理 AI 工作流资产。
清理 C 盘的回退副本
删除前,我把已改名的 C 盘实体目录与 F:\code 又比了一次。迁移后的日志仍列出了 1,268 个 Git 对象。我把这些差异项逐个做 SHA-256 比对,结果全部一致,区别在时间戳。这次哈希检查覆盖的是 Robocopy 报出的差异项。随后我也确认了桌面联接的目标和 Codex 的规则入口,才清理回退副本。
清理时要认准 code.before-f-migration 这个 C 盘实体目录。里面也可能有目录联接和符号链接,我用的清理程序遇到链接就只移除链接条目,跳过目标目录。实际释放量用 Get-Volume -DriveLetter C 的 SizeRemaining 在清理前后测量。本机从约 12.1 GiB 可用增到 180.6 GiB,净增约 168.5 GiB。