- Shell 100%
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. |
||
|---|---|---|
| bin | ||
| completions | ||
| functions | ||
| tests | ||
| LICENSE | ||
| README.md | ||
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 toorigin/mainand tracking it, sogit pullworks. Create your own branch from there withgit switch -c. Git lets only one worktree check out a branch, so if another workdir already hasmain, you get a detached checkout oforigin/HEADinstead, andwdtells you which workdir holdsmain. That is the same commit, and a branch made from it behaves the same. A localmainthat 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 inwd init LPS-123-login): if the repo has a branch for that ticket (LPS-123,LPS-123-*orLPS-123_*, local or on origin;LPS-12doesn't matchLPS-123), that branch is checked out instead ofmain, up to date with and tracking origin. With several, fzf asks which; its last entry,main (no LPS-123 branch), followsmainafter all, and Esc skips the repo. If another workdir has the branch, you get it detached atorigin/<branch>, as withmain. No matching branch: as above.-w,-b,-dand--directskip the lookup. -w/--wd-branch: a branch named after the workdir. It is checked out if it exists locally, tracksorigin/<name>if only that exists, and is created fromorigin/HEADotherwise.-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.-balone (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, throughgh. 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 intorepos/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, andwdsays how to update it.--take(with-w,-bor--pr): git lets only one worktree have a branch checked out. If another workdir has it,wd addstops and says so; with--takeit detaches the branch there (same commit, files untouched) and checks it out here.-d/--detach: always detached atorigin/HEAD.--direct: a plaingit cloneof a url straight into the workdir, bypassingrepos/.
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--directclone - 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
--directclone 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; setwd_disposableto override that list. - a loose entry refuses to go:
wdcan'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: afterwd initcreated the folderpost-add: afterwd add, with the new checkouts as arguments;WD_TABis also set to1whenwd add -twas given, for hooks that open something per checkout, like a terminal tabpre-delete: beforewd deleteremoves anything; a non-zero exit stops it, unless--forcepost-delete: after the folder is gonepre-rm,post-rm: the same aroundwd rm, with the entries as arguments (afterpost-rmthey no longer exist); the workdir stays, so a hook that cleans up after a whole workdir belongs topost-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