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:

  1. z can only cd. You can't say cp {z baz}/parse.py {z sfoo} — the moment you need a project's path inside a command, you're back to typing it out.
  2. Frecency only helps with projects you visit. The ones you forgot aboutmidbar and friends — are exactly the ones with no recent rank, and the ones you most need help finding.
  3. 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 up midbar.
  • 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.