No description
Find a file
Tom Lazar 77a19b5391 feat(review): wd review <url>, a workdir of its own for a pull request
Creates review-<repo>-<number>, cds into it and adds the repo with --pr; for one
that exists, it just goes there. The pull request is checked with gh first, so
a wrong url leaves no empty workdir behind.
2026-10-27 15:33:29 +01:00
bin wd: workdirs of git worktrees, with lifecycle hooks 2026-10-09 13:30:03 +02:00
completions feat(review): wd review <url>, a workdir of its own for a pull request 2026-10-27 15:33:29 +01:00
functions feat(review): wd review <url>, a workdir of its own for a pull request 2026-10-27 15:33:29 +01:00
tests feat(review): wd review <url>, a workdir of its own for a pull request 2026-10-27 15:33:29 +01:00
LICENSE wd: workdirs of git worktrees, with lifecycle hooks 2026-10-09 13:30:03 +02:00
README.md feat(review): wd review <url>, a workdir of its own for a pull request 2026-10-27 15:33:29 +01:00

wd — workdirs of git worktrees

wd is a fish function that organises git checkouts as named workdirs, one folder per task, each holding worktrees of whichever repos that task touches. Each repo is cloned once as a bare repo, and every workdir gets its own worktree of it:

~/Development/
  repos/<repo>.git            bare clones, origin/* refs like a normal clone
  workdirs/<name>/<repo>      worktrees (or --direct plain clones)

$WD_ROOT replaces ~/Development, e.g. for trying things out in a temp dir.

The workdir is meant as the anchor other tools attach to: terminal sessions, editor projects, browser tabs, agent sessions. wd itself only deals with checkouts and leaves the rest to hooks, which attach tools to its events, and commands, which add verbs of your own.

Install

Needs fish and git; fzf is optional (fuzzy repo names, picking branches), and so is gh (--pr). Put functions/ and completions/ on fish's search paths, e.g. in config.fish:

set -l wd ~/.local/share/wd
if test -d $wd
    set -p fish_function_path $wd/functions
    set -p fish_complete_path $wd/completions
end

With chezmoi, .chezmoiexternal.toml can keep that clone up to date:

[".local/share/wd"]
    type = "git-repo"
    url = "https://git.tomster.org/tomster/wd.git"
    refreshPeriod = "168h"

Usage

wd init <name> [add options] [repo|url...]   create workdirs/<name>, cd into it, add repos
wd add [-t] [-w | -b [BRANCH] | --pr N|URL | -d | --direct] <repo|url>...   (inside a workdir)
wd take [branch]                             switch this checkout to a branch, taking it from the worktree that has it
wd status [<name> | --all]                   what a workdir holds that is not pushed
wd rm [--force] <entry>...                   remove entries of the current workdir, unless something would be lost
wd delete [--force] <name>                   delete a workdir, unless something would be lost
wd review [-t] <pull request url>            a workdir for reviewing a GitHub pull request
wd <command> [args...]                       run one of your commands

A repo can be given as a url, which clones it into repos/ on first use, or by the name of an existing repos/*.git. A name that doesn't match exactly is fuzzy-matched with fzf. wd add fetches first, so it starts from the current state of origin. Completion for -b offers the branches of the repos already on the command line (wd add premium-services -b <TAB>), local and origin's, and says when one is checked out in another workdir; before any repo, it offers the branch names of all repos.

What gets checked out:

  • default: the default branch (main), fast-forwarded to origin/main and tracking it, so git pull works. Create your own branch from there with git switch -c. Git lets only one worktree check out a branch, so if another workdir already has main, you get a detached checkout of origin/HEAD instead, and wd tells you which workdir holds main. That is the same commit, and a branch made from it behaves the same. A local main that has diverged from origin is left alone, with a warning.
  • a ticket workdir (default mode, name starting with a ticket key like LPS-123, as in wd init LPS-123-login): if the repo has a branch for that ticket (LPS-123, LPS-123-* or LPS-123_*, local or on origin; LPS-12 doesn't match LPS-123), that branch is checked out instead of main, up to date with and tracking origin. With several, fzf asks which; its last entry, main (no LPS-123 branch), follows main after all, and Esc skips the repo. If another workdir has the branch, you get it detached at origin/<branch>, as with main. No matching branch: as above. -w, -b, -d and --direct skip the lookup.
  • -w/--wd-branch: a branch named after the workdir. It is checked out if it exists locally, tracks origin/<name> if only that exists, and is created from origin/HEAD otherwise.
  • -b BRANCH: the same, but for the branch name you give. A local branch that is behind origin's is fast-forwarded first and set to track it, so a branch you looked at before shows its current state.
  • -b alone (last, or followed by another option: wd add premium-services -b): fetches, then fzf picks from the repo's branches, local and origin's, most recent commit first, with when, who and what, and which workdir has one checked out. Then as with -b BRANCH. With several repos, each gets its own pick; Esc skips the repo.
  • --pr N|URL: the branch of a GitHub pull request, for reviewing, through gh. A number needs the repo (wd add --pr 123 premium-services); a url says which it is (wd add --pr https://github.com/o/r/pull/123), cloning it into repos/ if it isn't there yet. The branch is then checked out as with -b. A pull request from a fork has no branch on origin: you get a detached checkout of its head, and wd says how to update it.
  • --take (with -w, -b or --pr): git lets only one worktree have a branch checked out. If another workdir has it, wd add stops and says so; with --take it detaches the branch there (same commit, files untouched) and checks it out here.
  • -d/--detach: always detached at origin/HEAD.
  • --direct: a plain git clone of a url straight into the workdir, bypassing repos/.

wd review <url> is the shortcut for reviewing a pull request: it creates the workdir review-<repo>-<number>, cds into it and adds the repo with --pr <url>. Run it again with the same url, from anywhere, and it takes you back there; wd delete it once the review is done. It checks with gh that the pull request exists before creating anything.

Git lets only one worktree have a branch checked out, so you can't just git switch main in a checkout when another one has main. wd take [branch], run inside a checkout, does it for you: it switches to the branch (default: the default branch), detaches it in the worktree that had it (same commit, files and local changes there stay), and says so. Like wd add it fetches first, fast-forwards the branch to origin and sets its upstream; a branch only origin has is created tracking it. It switches before touching the other worktree, so local changes in the way stop it with nothing moved. In a --direct clone it is a plain switch. wd take <TAB> lists the repo's branches and where they are held. The stock alternative, git switch --ignore-other-worktrees, leaves two worktrees on one branch, which end up disagreeing with each other.

A detached checkout doesn't move on git pull; git fetch and git switch --detach origin/<branch> bring it up to date.

Entries

A workdir holds entries, its top-level paths. Mostly they are checkouts, but it is a container for whatever belongs to the task: a symlink to a shared folder, a PDF of an invoice, a photo of a sketch. wd tells them apart without following links:

  • checkout: a worktree of a repo in repos/, or a --direct clone
  • link: a symlink, whatever it points to
  • loose: anything else, a file or a folder

wd status shows each entry by kind. wd rm and wd delete judge each one by what removing it loses:

  • a link is always safe: only the link goes, never what it points to
  • a checkout refuses to go while it has uncommitted, untracked or ignored files, or commits that exist only on a detached HEAD; a --direct clone also while it has stashes or unpushed commits (a worktree's stay safe in the bare repo). Regenerable ignored paths (.venv, node_modules, caches, …) never block; set wd_disposable to override that list.
  • a loose entry refuses to go: wd can't tell whether it is the only copy

wd delete <name> deletes the workdir if all of that is fine, else lists what holds it back and stops; --force deletes anyway. wd rm <entry>... does the same for entries of the workdir you are in (tab completion lists them), leaving the workdir, empty or not. Remove checkouts this way rather than with rm -rf: the worktree is then unregistered properly, and the hooks run.

Hooks

Other tools attach to workdirs through hooks rather than inside wd: every executable in ~/.config/wd/hooks/<event>.d/ runs, in order, with WD_EVENT, WD_NAME, WD_DIR and WD_ROOT set, in the workdir (or in workdirs/ once it is gone). $WD_HOOKS replaces the hooks dir. wd add -t is how you ask hooks that do tabs for one.

  • post-init: after wd init created the folder
  • post-add: after wd add, with the new checkouts as arguments; WD_TAB is also set to 1 when wd add -t was given, for hooks that open something per checkout, like a terminal tab
  • pre-delete: before wd delete removes anything; a non-zero exit stops it, unless --force
  • post-delete: after the folder is gone
  • pre-rm, post-rm: the same around wd rm, with the entries as arguments (after post-rm they no longer exist); the workdir stays, so a hook that cleans up after a whole workdir belongs to post-delete

A failing post-hook only prints a warning. Example, deleting the workdir's zellij session:

#!/usr/bin/env bash
# ~/.config/wd/hooks/post-delete.d/zellij
zellij delete-session --force "$WD_NAME" 2>/dev/null || true

Commands

Hooks run on wd's events; commands are verbs you add. wd <command> [args...] runs the executable ~/.config/wd/commands/<command> with the arguments, in the current directory, with WD_COMMAND, WD_NAME, WD_DIR and WD_ROOT set (WD_NAME and WD_DIR are empty outside a workdir). $WD_COMMANDS replaces the commands dir. Its exit status is wd's. The built-ins can't be overridden; wd's usage lists the commands there are, and completion offers them, described by their second line:

#!/usr/bin/env bash
# wd edit: open the workdir in the editor
exec "${VISUAL:-${EDITOR:-vi}}" "${WD_DIR:-.}"

Migrating flat clones

bin/wd-migrate.sh [--apply] [--claude] [repo...] turns old-style ~/Development/<repo> clones into repos/<repo>.git plus workdirs/<repo>/<repo>. It moves files rather than copying them, and verifies that no files, refs or stashes were lost. It does a dry run unless --apply is given. --claude also moves the repo's Claude Code project dir (memory, transcripts) to the new path.

Development

fish tests/wd.fish

runs everything in a temp dir, against functions/wd.fish (or $WD_SRC). To try changes from a checkout in your own shell, put it first on the search path for that session:

set -p fish_function_path (pwd)/functions; and source functions/wd.fish