Repository navigation
Releases
v0.19.0
The emulator images move
Android Studio writes a writable image for every emulator that it creates, and
the image grows with each app. That folder is now a row: android.avd.
| Provider | What moves | Variable |
|---|---|---|
android |
The build cache, and the emulator images | ANDROID_AVD_HOME names the image root |
The move warns that a slow disk slows every launch, so pick a fast external
SSD. The build cache row keeps its Gradle lock; the image row refuses while
emulator or qemu-system-* runs. A row now names its own processes, so a
move of one row never waits on the program of another.
Evidence in a test home folder
$ exodrive check
android.build-cache | 6 B | .android/build-cache
android.avd | 8.0 MB | .android/avd
$ exodrive migrate --target=… --tools=android
✅ Migrated Android Build Cache.
✅ Migrated Android Emulator Images.
Migration completed. Reclaimed 8.0 MB on the local disk.
$ ls -l .android
avd -> …/drive/android/avd
build-cache -> …/drive/android/build-cache
$ exodrive verify --all
[ok] android.avd: … (3 files, 8.0 MB)
[ok] android.build-cache: … (1 file, 6 B)
Checked 2 copies. Every copy matches its move.
One process table, two answers:
$ exodrive preflight android.avd --target=…
Preflight failed for android.avd: emulator is running (PID: 99999): stop it before migration
$ exodrive preflight android.build-cache --target=…
Preflight passed for android.build-cache on destination …
exodrive rollback restored both folders, and the ledger was empty again.
What this release does not prove
This Mac has no emulator image folder, so the evidence above comes from a test
home folder with an 8 MB image. An emulator was not started from a linked
folder: the emulator reads its images through the link like any other tool, and
the row refuses the move while one runs.
v0.18.0
Two folders called bun cache
bun 1.4 fills <BUN_INSTALL>/install/cache: an install writes the packages
there. The provider also read ~/Library/Caches/bun. That folder came from an
earlier guess, and bun never reads it.
The provider now reads the folder that bun prints, then the install folder that
BUN_INSTALL names. The guess is gone, so a scan cannot describe a link that no
tool follows.
This Mac showed both halves of the problem:
Bun Package Cache package_store 235.4 KB Blocked symlink /Users/…/.bun/install/cache
└── [!] Exodrive recorded a move of /Users/…/Library/Caches/bun. This tool reads
/Users/…/.bun/install/cache. Run 'exodrive rollback bun.cache' to return the copy.
A test run moved a folder from this Mac, and the record named it after a folder
that bun stopped reading. The card claimed Migrated, so nothing showed the
mismatch.
A record speaks only for the folder that it moved
The scan now compares the recorded folder with the folder that the item
reports. When they differ, the row keeps its place but takes no move claim, and
the reason names the recorded folder, the folder that the tool reads, and the
rollback command.
exodrive migrate also refuses an item whose ID has a record, so the row cannot
hide behind a second move.
The repair on this Mac:
$ exodrive rollback bun.cache
Restored bun.cache. The ledger record is removed.
$ exodrive check
bun.cache | /Users/…/.bun/install/cache | 241090 | migrated=false | blocked=-
The copy is back at ~/Library/Caches/bun, which holds 1.8 MB. bun does not
read that folder, so you can delete it. Exodrive does not delete data on its
own.
What this release does not prove
The @t@ files in ~/Library/Caches/bun came from a bun build that this
version does not reproduce on this Mac. bun 1.4.2 wrote every probe install into
install/cache and none into ~/Library/Caches/bun, so Exodrive follows the
folder that bun fills.
v0.17.0
Two Go analysis caches
golangci-lint and gopls keep an analysis cache that they rebuild on the
next run. Exodrive moves the folder and puts a symbolic link in its place.
| Provider | What moves | Variable |
|---|---|---|
golangci-lint |
The analysis cache | GOLANGCI_LINT_CACHE |
gopls |
The analysis cache | none; the system cache folder |
The golangci-lint row refuses while golangci-lint runs, because a lint run
writes the cache. The gopls row names no process: an editor keeps gopls
running, so the lock would stop a move on every machine with an open editor.
The window rail shows both rows in the Go group.
Evidence on this Mac
$ exodrive migrate --target=/Volumes/PHD/… --tools=golangci-lint
✅ Migrated golangci-lint Cache.
Migration completed. Reclaimed 17.2 MB on the local disk.
$ ls -ld ~/Library/Caches/golangci-lint
lrwxr-xr-x golangci-lint -> /Volumes/PHD/…/go-tools/golangci-lint
$ golangci-lint run ./...
golangci-lint: No issues found (2.0s, a warm cache)
$ exodrive verify --all
[ok] golangci-lint.cache: … (54317 files, 17.2 MB)
A file created through the link landed in the copy on the drive, so the link
carries writes as well as reads. exodrive rollback golangci-lint.cache
restored a real folder, and golangci-lint cache status read 17.2 MiB again.
What this release does not prove
The gopls cache was not moved on this Mac: five gopls processes hold it, and
the row has no lock by design. The move itself uses the same code as the
golangci-lint row, and a test covers it.
v0.16.0
Three browser stores
Playwright, Cypress, and Puppeteer download a browser or a binary bundle and
keep it in one folder. Exodrive moves that folder and puts a symbolic link in
its place, so each tool keeps reading the path it knows.
| Provider | What moves | Variable |
|---|---|---|
playwright |
The browsers that Playwright downloaded | PLAYWRIGHT_BROWSERS_PATH |
cypress |
The binary bundle that Cypress downloaded | CYPRESS_CACHE_FOLDER |
puppeteer |
The browsers that Puppeteer downloaded | PUPPETEER_CACHE_DIR |
PLAYWRIGHT_BROWSERS_PATH=0 tells Playwright to keep the browsers in
node_modules. The provider reads the system cache folder for that value, so
the string 0 never becomes a path.
No process lock names these tools. The run that reads the store is node, and
the browser program names are shared with programs that never touch the folder.
A name lock would miss the writer or stop an unrelated program, so the row
carries the next step instead.
Evidence on a real store
This Mac holds 1.19 GB of Playwright browsers (9 folders, 787 files).
$ exodrive check
Playwright Brow.. package_store 1.1 GB Migrated symlink ...gley/Library/Caches/ms-playwright
$ time exodrive migrate --target=/Volumes/PHD/… --tools=playwright
✅ Migrated Playwright Browsers.
Migration completed. Reclaimed 1.1 GB on the local disk.
16.315 total
$ exodrive verify --all
[ok] playwright.browsers: …/playwright/browsers (787 files, 1.1 GB)
$ ls -ld ~/Library/Caches/ms-playwright
lrwxr-xr-x … ms-playwright -> /Volumes/PHD/…/playwright/browsers
The browser runs from the linked folder:
$ ~/Library/Caches/ms-playwright/chromium_headless_shell-1254/…/chrome-headless-shell --version
Google Chrome for Testing 158.0.8127.18
exodrive rollback playwright.browsers restored a real folder with the same
1.1 GB, and the ledger was empty again. The move used a separate record file,
so the record of this Mac stayed untouched.
What this release does not prove
A full Playwright test run through the link was not run: this Mac has the
browsers, not a project that starts them. The browser binary itself runs from
the linked folder, which covers the read path.
v0.15.0
Four more toolchains
The same five steps move four more families: Ruby, Composer, NuGet, and the
caches that download and build tools keep.
| Provider | What moves | Switch |
|---|---|---|
ruby |
The Bundler cache, and the cache of every user gem folder | Symbolic link for each folder |
composer |
The download cache | Symbolic link |
nuget |
The global package folder | Symbolic link |
devtools |
Electron downloads, node-gyp headers, and the tsc cache | Symbolic link for each folder |
A gem folder carries the Ruby version in its name, so the scan finds the
folders each time. Each folder keeps an ID from its path, so a scan never
renames a cache that the ledger holds.
BUNDLE_USER_CACHE, COMPOSER_CACHE_DIR, and NUGET_PACKAGES name a folder
that the link replaces. The variable still names a path that works, so a move
does not refuse it.
The rail gains a Ruby group. Composer, NuGet, and the dev tool caches show
under Other.
devtools names no process. node runs long-lived programs, so a lock on it
would stop a move on a machine that builds nothing. Stop the build before the
move.
Evidence
A move of all seven caches in a test home folder:
$ exodrive migrate --target=/tmp/exo-c/drive --tools=ruby,composer,nuget,devtools
==> Migrating Bundler Cache (…/.bundle/cache, 4.0 KB) by symbolic link...
✅ Migrated Bundler Cache.
==> Migrating RubyGems Cache (3.3.0) (…/.gem/ruby/3.3.0/cache, 4.0 KB)…
✅ Migrated RubyGems Cache (3.3.0).
Migration completed. Reclaimed 28.0 KB on the local disk.
$ ls -l ~/.bundle ~/.gem/ruby/3.3.0 ~/.nuget ~/Library/Caches
cache -> /tmp/exo-c/drive/ruby/bundler-cache
cache -> /tmp/exo-c/drive/ruby/gem-cache-bfea8de3
packages -> /tmp/exo-c/drive/nuget/packages
composer -> /tmp/exo-c/drive/composer/cache
electron -> /tmp/exo-c/drive/devtools/electron
node-gyp -> /tmp/exo-c/drive/devtools/node-gyp
typescript -> /tmp/exo-c/drive/devtools/typescript
$ exodrive verify --all
[ok] composer.cache: /tmp/exo-c/drive/composer/cache (1 file, 4.0 KB)
[ok] devtools.electron: /tmp/exo-c/drive/devtools/electron (1 file, 4.0 KB)
[ok] devtools.node-gyp: /tmp/exo-c/drive/devtools/node-gyp (1 file, 4.0 KB)
[ok] devtools.typescript: /tmp/exo-c/drive/devtools/typescript (1 file, 4.0 KB)
[ok] nuget.packages: /tmp/exo-c/drive/nuget/packages (1 file, 4.0 KB)
[ok] ruby.bundler-cache: /tmp/exo-c/drive/ruby/bundler-cache (1 file, 4.0 KB)
[ok] ruby.gem-cache-bfea8de3: /tmp/exo-c/drive/ruby/gem-cache-bfea8de3 (1 file, 4.0 KB)
Checked 7 copies. Every copy matches its move.
A changed file in one copy reads as changed, and the command exits 1. Every
rollback restored a real folder and removed its record. The ledger was empty
again.
What this release does not prove
This Mac holds none of the seven folders, so the evidence above comes from a
test home folder. The scans on this Mac found no cache of these tools. That is
the same rule as before: a folder that does not exist has no row.
The Ubuntu job failed on the first push. TestLifecycle_Devtools built the
folder under Library/Caches, which is the macOS rule only. The test now
states the platform rule itself, and it passes on Linux in a container.
v0.14.0
A faster scan
exodrive check measured every cache with one folder at a time. The Go stores
on this Mac hold 362k entries, so a scan took about 30 seconds.
DirSize now reads up to eight folders at once. Every call in a scan shares
those readers, so the 22 providers cannot flood one disk.
| Measure | Before | After |
|---|---|---|
| Go provider | 28.7s | 6.8s |
| pnpm provider | 15.1s | 1.8s |
| Go module cache, 6.9 GB | 10.0s | 2.9s |
Full exodrive check |
about 30s | about 10s |
A cancelled scan still stops. A link inside a tree still adds nothing, and a
cache that sits behind a link is still measured through the link. A test covers
both rules, and it fails when the walk follows a link.
What did not change
The window still measures every cache after each action, so a rescan on a
machine with a large Go store still takes about 10 seconds. Showing the list
first and filling the sizes later is a separate change.
v0.13.0
A cache that a tool wrote again
Some tools delete the symbolic link and write their cache folder again. The
ledger keeps the record, so exodrive check said Migrated. The card offered a
move back that always failed.
A scan now reads the old path of a moved cache. These cases count as
Rewritten:
- A real folder sits at the path again.
- The link points at another folder.
- Exodrive cannot read the path.
The folder is on this Mac, so its bytes count as local. The card offers
Keep the local folder without a failed press first.
exodrive check
exodrive rollback --keep-local <item>A path that is gone keeps the state it had. So does a record that moved the
data with a tool setting. A record that names another path does not describe
the item, so the scan claims nothing about its link.
Evidence
A fixture home held two Dart caches. The released v0.13.0 binary moved both by
link. A simulated tool then wrote one folder again.
| Step | Result |
|---|---|
check after the tool wrote again |
Dart hosted reads Rewritten with the next step; Dart git reads Migrated |
| Totals | The hosted bytes count as local; the git copy stays on the drive |
rollback dart.pub-cache |
Exit code 1: exists and differs from … |
rollback --keep-local dart.pub-cache |
Exit code 0, copy deleted, record removed |
check after the keep-local |
Dart hosted reads Internal |
A window test drives the same state. The card reads Rewritten, offers
Keep the local folder, and offers no move back or check.
v0.12.0
Keep the local folder
A tool can delete the link and write its cache folder again. The move then
cannot run, because the folder is no longer a link. The rollback cannot run
either, because the local folder no longer matches the copy.
exodrive rollback --keep-local <item> ends that state. Exodrive deletes the
copy on the drive, keeps the folder on this Mac, and removes the ledger
record.
exodrive rollback --keep-local dart.pub-cacheThe window offers the same action on a failed card. Select Keep the local
folder, then confirm.
The command refuses three cases, so a record never goes while the data is out
of reach:
- The path still links to the copy. Run the rollback instead.
- The original folder is gone. Run the rollback from the drive.
- The drive is not reachable. Connect the drive first.
The process lock no longer blames the shell
Exodrive stopped a conda move with conda is running (PID: …) when no conda
command ran. The lock read the whole command line, so the shell that started
Exodrive matched its own text.
The check now reads the program and the paths that name a tool. It skips the
processes above Exodrive. A tool that runs through an interpreter is still
found: node …/npm-cli.js, python -m pip, and the Gradle daemon class.
Evidence
A fixture home held one Dart package cache. The released v0.12.0 binary moved
it by link. A simulated tool then wrote the folder again.
| Step | Result |
|---|---|
migrate --tools=dart.pub-cache |
Moved by link, record written |
| Tool writes the folder again | Local folder is real, copy holds 2 files |
rollback dart.pub-cache |
Exit code 1: exists and differs from … |
rollback --keep-local dart.pub-cache |
Exit code 0, copy deleted, records: {} |
The lock checks used the same fixture:
- A sibling shell whose command line held
grep -i dartdid not stop the
move. The move succeeded with exit code 0. This command reproduces the
v0.11.0 false positive. - A live
/bin/sh /tmp/exo-v12/bin/dartstopped the move with exit code 1:
preflight failed: dart is running (PID: 3207): stop it before migration.
Known limit
A scan measures the size of every cache that it finds. A large Go module store
or pnpm store adds seconds to exodrive check. The move itself touches only
the selected cache.
v0.11.0: Dart, Android, Conda, and Homebrew caches
Dart, Android, Conda, and Homebrew caches
Four new providers move the caches that these toolchains leave behind.
| Provider | What moves |
|---|---|
| Dart and Flutter | hosted/ and git/ under the pub cache |
| Android | ~/.android/build-cache |
| Conda | Every package folder that conda info reports |
| Homebrew | downloads/ under the Homebrew cache |
Every row moves by symbolic link, so the tool keeps its path.
exodrive check
exodrive migrate --target=/Volumes/External/exodrive --tools=dart,homebrewThe window rail gains a Mobile group and a Homebrew group. Conda
appears under Python.
What stays on this Mac
Dart and Flutter keep bin/ and global_packages/ on the internal disk. A
global command on PATH keeps working when the drive is away.
Homebrew keeps api/. Every brew command reads the formula index there.
Android keeps the emulator data. An emulator on another volume runs far
slower.
Conda asks the tool
Exodrive reads the package folders from conda info --json. It never guesses
a prefix. A missing tool adds no card, and a reply that it cannot read is an
error on the scan.
A package cache keeps its ID when another cache appears or goes away. The ID
comes from the path, not from the position in the list.
conda hardlinks packages into an environment on the same volume. Across
volumes it copies them, so an environment uses more space. The card says so.
Process locks
A move stops while the tool runs:
- Dart and Flutter:
dart,flutter, orpub. - Android:
gradleor the Gradle daemon. Rungradle --stopfirst. - Conda:
conda,mamba, ormicromamba. - Homebrew:
brew.
Evidence
A fixture home moved 5 caches by link: Dart hosted and git, the Android build
cache, one conda package folder, and the Homebrew downloads. All 5 copies
verified. A damaged byte in one copy showed changed and exit code 1. All 5
rollbacks restored the folders and emptied the ledger.
On a real Mac, the Homebrew download cache (199.5 MB, 5 files) moved by link.
verify --all reported ok, and brew --cache jq returned a path through
the link. The rollback restored the folder (200 MB) and left the ledger empty.
v0.10.0: Xcode, JVM, and Apple dependency caches
Xcode, JVM, and Apple dependency caches
Five new providers move the folders that grow fastest on a Mac that builds
for Apple platforms and runs JVM builds.
| Provider | What moves |
|---|---|
| Xcode | DerivedData, Archives, iOS/watchOS/tvOS/visionOS Device Support, Previews, and the Xcode app cache |
| Maven | repository/ |
| Gradle | caches/ and wrapper/dists/ |
| CocoaPods | The pod cache and the spec repos |
| SwiftPM | The package cache |
Every row moves by symbolic link, so the tool keeps its path.
exodrive check
exodrive migrate --target=/Volumes/External/exodrive --tools=xcode,gradleThe window rail gains an Apple group and a JVM group.
What stays on this Mac
Exodrive does not move ~/Library/Developer/CoreSimulator/Devices. A link
there stops Xcode from creating simulator devices. It also breaks the Files app
inside the simulator.
The simulator folder stays in the list with the state Cannot move and the
reason. You decide what to delete in Xcode.
Exodrive also never moves ~/Library/Developer/Xcode/UserData or
UserData/IB Support. Asset catalog builds write there.
One guard for a Java tool
The process lock now reads a Java main class. The Gradle daemon runs as
org.gradle.launcher.daemon.bootstrap.GradleDaemon, so gradle --stop clears
the lock.
Evidence
This release passed these checks on macOS 26:
- The full suite with the race detector,
go vet,gofmt,golangci-lint,
and the site check. Windows and Linux cross-compile. - A test home with every new folder: 14 moves, then
exodrive verify --all
reported 14ok. One changed byte reportedchangedand exit code 1.
14 rollbacks left an empty ledger and restored every file. - A real move on this Mac:
~/.gradle/wrapper/dists(652.1 MB, 22,429 files)
and~/.cocoapods/repos(2.5 MB, 1,744 files). The check reportedokfor
both. Both rollbacks restored the folders and emptied the ledger.
Xcode reads the moved folders through the links. This machine holds no Xcode
build output, so a live Xcode build through a link is not proven.
Also
README.md,README.zh.md,SPEC.md, and both docs pages describe the five
providers and the blocked simulator folder.- A custom Derived Data location is not read. A cache at that location stays
out of the list.