A multiplexer for the ever-growing pile of projects
My ~/work directory only ever grows. Big projects, medium projects, one-afternoon
experiments that never got deleted, clones of other people's code kept around for
reference. Twenty-six directories at last count, and I had stopped being able to answer
basic questions about my own machine: where did I put that script? What did I even call
it? Is it git-tracked, or one rm -rf away from oblivion?
For the rest of this post, let three made-up projects stand in for the whole zoo:
- superfoo — the big website. Gets attention almost every day.
- midbar — a log-analysis system I had to build once and never cared about again.
- tinybaz — a script that parses metadata out of a pile of files. Tiny, but I still reach for it regularly.
I use z (via the oh-my-zsh plugin), and jumping around was
never the problem — z sfoo lands me in superfoo just fine. The problems were
everything around the jump:
zcan onlycd. You can't saycp {z baz}/parse.py {z sfoo}— the moment you need a project's path inside a command, you're back to typing it out.- Frecency only helps with projects you visit. The ones you forgot about —
midbarand friends — are exactly the ones with no recent rank, and the ones you most need help finding. - Nothing nags you about hygiene, so half the projects end up without git, without a README, or with months of unpushed commits.
I was tempted to build A System™ — a project database, metadata files, an index. I'm
glad I didn't, because the whole thing came down to about a hundred lines of zsh built
on one principle: the filesystem is the database. Every listing is derived by
scanning ~/work at call time, so nothing can ever go stale. The only convention I had
to adopt: each project's README starts with a one-line description.
The cake first
Before the recipe, here's what day-to-day feels like with the whole thing in place:
# what did I call that log-analysis thing, and where did I put it?
$ work find logs
midbar One-off log analysis for the 2024 gateway migration.
# jump around with fuzzy names, as `z` always allowed...
$ z sfoo
# ...but project names now also work *inside* commands
$ cp ~[baz]/parse_meta.py ~[sfoo]/scripts/
# what's rotting?
$ work doctor
midbar no git
superfoo unpushed 3
tinybaz no remote
(23 projects clean)
# new projects start life with git, a README and a jump entry
$ work new quuxwatch "Alerting for the quux fleet"
/home/mmv/work/quuxwatch
# retiring a project doesn't break your jump muscle memory
$ work archive midbar
/home/mmv/work/archive/midbar
$ z midb && pwd
/home/mmv/work/archive/midbar
Everything below is the recipe for the above, piece by piece.
zoxide instead of z
zoxide is the actively maintained successor of
z, with the same muscle memory. The killer difference is that it's composable:
zoxide query sfoo prints the resolved path instead of cd-ing into it. Migrating took
two commands:
sudo apt install zoxide
zoxide import --from=z ~/.z # keep years of frecency habits
Then in .zshrc, replace the z plugin with eval "$(zoxide init zsh)". A trivial
wrapper gives you paths in command substitutions:
zq() { zoxide query -- "$@" }
cp $(zq baz)/parse_meta.py $(zq sfoo)/scripts/
~[name]: the zsh feature nobody told you about
zsh has dynamic named directories:
if you define a zsh_directory_name function, then ~[whatever] anywhere on a command
line expands through it. Wire it to zoxide and you get fuzzy project names as first-class
path syntax:
zsh_directory_name() {
[[ $1 == n ]] || return 1
local dir
dir=$(zoxide query -- "$2" 2>/dev/null) || return 1
typeset -ga reply=("$dir")
}
cp ~[baz]/parse_meta.py ~[sfoo]/scripts/
vim ~[midb]/README.md
It tab-completes, it works in any command, and it reads almost like what I wished for in
the first place. (If something else in your setup also defines zsh_directory_name,
append to the zsh_directory_name_functions array instead of clobbering the function.)
work: list, find, doctor, new
The forgetting problem and the hygiene problem are handled by one small script with four
verbs, all of them just scans over ~/work:
work new <name> [desc]— mkdir +git init+ README with the description + first commit. New projects start compliant, so the convention maintains itself.work list— every project with the first non-heading line of its README next to it.work find <pattern>— matches project names and greps all the READMEs. This is the "I know I analyzed those logs somewhere" command that digs upmidbar.work doctor— flags projects with no git, no README, dirty worktrees, unpushed commits. Running it the first time was humbling: 12 of 26 projects weren't git-tracked at all, and one had 506 unpushed commits.
None of these keep any state. Deleting a project directory is removing it from the "database".
Bonus: an archive that zoxide follows
Retiring old projects to ~/work/archive/ keeps the active listing short, but a plain
mv breaks your jump history — zoxide still points at the old path. So work archive
moves the project and remaps every zoxide entry beneath it:
work-archive() {
local dir=$WORK_DIR/$1 dest=$WORK_DIR/archive/$1
[[ -d $dir ]] || return 1
mkdir -p $WORK_DIR/archive
# capture the list BEFORE the move: zoxide query --list
# silently omits paths that no longer exist
local -a zpaths=(${(f)"$(zoxide query --list)"})
mv $dir $dest
local p
for p in $zpaths; do
[[ $p == $dir || $p == $dir/* ]] || continue
zoxide remove "$p"
zoxide add "$dest${p#$dir}"
done
}
The comment marks the one real gotcha: query the database before the mv. zoxide
hides entries whose directories no longer exist, so if you move first there is nothing
left to remap — I found out the hard way. The remap resets frecency scores (there's no
"edit" operation, only remove + add), which for archived projects is arguably a feature.
After archiving, z oldproject still lands you in the right place — it's just under
archive/ now, and work list no longer mentions it.
Takeaway
I expected this to become a real tool with a real name (it's wmplex in my ~/work,
because of course the tool for managing projects is itself a project). It turned out to
be a shell script and two config stanzas. If your project directory is drifting out of
control, you probably don't need A System either — you need zoxide, one README
convention, and a hundred lines of zsh.