Git Project-Organization Tooling: ghq and the 4-Category Landscape
Status: Adopted + migrated 2026-06-26. Core config in commit
0eedb8e(ghq,ghq.root = ~/projects,projfzf-jump).~/projectsmigrated to host/owner/repo layout (45 repos moved, 2 duplicate pairs resolved). Deferred: work GHE identity (includeIf).
Core reframe
“Controlling where project repos live” conflates 4 distinct jobs. Most tool confusion comes from treating them as one.
| Job | What it does | Tools | My status |
|---|---|---|---|
| Clone organization | Where canonical repos land on disk | ghq, rrc, gh-q, gogh | adopted: ghq → ~/projects |
| Worktree management | Parallel branches in isolated dirs | gwq, worktrunk | have: worktrunk → ~/worktrees |
| Multi-repo bulk operations | Run commands across many repos | gita, mr, vcstool | not needed |
| Navigation | Jump between dirs | zoxide, fzf, sesh | have: zoxide + fzf-tab + tmuxinator + proj |
ghq — the clone-organization leader
host/owner/repolayout (~/ghq/github.com/owner/repo), modeled ongo get.- Configured via plain git-config:
ghq.root,ghq.user,ghq.defaultHost; supports multiple roots (GHQ_ROOTenv overrides all). - Navigate with
ghq list --full-path | fzf. No built-in relayout/migrate of existing flat clones — done with a one-off script (see below). - Maturity: ~3.7k stars, 81 releases, latest April 2026, in nixpkgs (installed 1.10.1).
ghq getnever searches across hosts: shorthandowner/reporesolves toghq.defaultHost(github.com) only; GHE needs the full URL.- Its
host/owner/repoidentity matches the project-id worktrunk derives from the remote URL, so the two compose with zero friction.
Key insight: gwq is a worktrunk peer, not a complement
The canonical writeup pushing this stack is shunk031’s A Coding-Agent-Friendly Environment Is Friendly to Humans Too, advocating ghq + gwq + fzf for parallel AI agents. But gwq occupies the same slot worktrunk already fills (worktrees with host/owner/repo=branch, tmux integration, status dashboard) — and worktrunk does more (lifecycle hooks, LLM commits, approval gating). So the community pattern validates the existing architecture; the only missing layer was the clone layer (ghq).
Alternatives that don’t displace ghq
- rrc — Rust, ~ghq-compatible CLI, root
~/repos; smaller community. - gh-q — a
ghextension reimplementation; GitHub-tied. - gogh — inspired by ghq, niche (search noise: an unrelated focus-timer app shares the name).
- gita / mr / vcstool — different category entirely: they operate across repos (status dashboards, batch git), they do not organize clone locations.
What was implemented (2026-06-26, commit 0eedb8e)
packages.nix:ghqadded tocoreTools(next to worktrunk).git.nix:ghq.root = "~/projects"(ghq expands the leading~; git leaves the value untouched).shell.nix:projsiteFunction —dir=$(ghq list --full-path | fzf) && cd "$dir".
Migration of legacy flat repos (executed 2026-06-26)
ghq has no relayout command, so a one-off scratchpad/ghq-migrate.sh (dry-run default, --execute to apply, optional repo-name whitelist) read each flat repo’s origin and mv’d it to <host>/<owner>/<repo>. Hardening that mattered:
- Parser repair: a malformed
https:///www.github.com/...(triple-slash empty authority) andwww.host variants were normalized togithub.com. - Collision pre-pass: two local repos mapping to one target are both flagged and skipped (dry-run no longer hides the second’s silent loss).
- Skips: no-origin (local-only), linked worktrees (would break worktrunk’s
~/worktreesgitdir links), existing targets, unparseable origins.
Result: 45 repos moved into the nested layout; 13 local-only repos stay flat (correct); 1 unparseable (jupyter-cc-context, local-path origin) left flat.
Two duplicate pairs resolved (both clones of one upstream; inspected for unique local state before deleting):
mypath+rustlings→ bothrust-lang/rustlings.mypathwas a stale clean clone (no commits/changes) → deleted.rustlings(24 uncommitted exercise files) kept and migrated.fresh-jlcc+jupyter-claude-code→ bothachhina/jupyterlab-claude-code. Both had unpushed work;fresh-jlcc(11 commits onfix/issue-57) deleted after bundling all refs + a wip snapshot to~/.local/share/ghq-migrate-backups/fresh-jlcc-<stamp>.bundle(2.9M).jupyter-claude-code(291+ unpushed commits, 3 feature branches, 1 stash) kept and migrated — verified intact post-move.
Lesson: “duplicate by remote” ≠ safe to delete. Always inspect dirty/stash/unpushed-commits/local-branches per clone first; snapshot unpushed commits to a git bundle before any destructive dedup. Aftermath: zoxide frecency DB holds stale paths (harmless; re-cd to reseed).
Work scenario: GitHub Enterprise + a different work username (deferred)
Personal github.com + work GitHub Enterprise is the easy multi-identity case, because the two are genuinely different hostnames. Every layer keys cleanly on the real host — no fake SSH aliases or url.insteadOf hacks (those are only needed for two accounts on the same host).
Layout is automatic — ghq derives the path from the URL host+path:
~/projects/
github.com/<personal-user>/repo # personal
ghe.work-company.com/<work-user>/repo # work (different subtree; work username = owner segment)
Per-identity git config via includeIf (the killer combo — ghq guarantees the directory boundary):
# ~/.gitconfig
[user]
name = Your Name
email = personal@example.com
[includeIf "gitdir:~/projects/ghe.work-company.com/"]
path = ~/.gitconfig-work
# ~/.gitconfig-work → [user] email = you@work-company.comGotchas: trailing / required; only matches inside a git repo; matches symlink-resolved path. In this repo, personal identity already lives in ~/.config/secrets/git/config (untracked, via programs.git.includes); a work identity should go there too, not in the committed git.nix.
SSH keys per host (real hostnames, no aliases): Host github.com → personal key, Host ghe.work-company.com → work key.
Two gotchas
- Don’t use
url.insteadOf+ fake host aliases here. ghq builds the directory from the URL you pass, so a fake alias (git@ghe-work:...) pollutes the tree as~/projects/ghe-work/.... Different real hosts → skip aliases entirely. - Worktrunk worktrees live outside the ghq tree (
~/worktrees/<repo>/<branch>, not under~/projects/ghe.../), so agitdir:~/projects/ghe.../include may not cover commits from a worktree. Verify empirically (git config user.emailinside a work worktree); if it falls back to personal, add a secondincludeIffor the worktrees path or nest worktrunk’sworktree-pathunder the host tree for work repos.