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, proj fzf-jump). ~/projects migrated 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.

JobWhat it doesToolsMy status
Clone organizationWhere canonical repos land on diskghq, rrc, gh-q, goghadopted: ghq → ~/projects
Worktree managementParallel branches in isolated dirsgwq, worktrunkhave: worktrunk~/worktrees
Multi-repo bulk operationsRun commands across many reposgita, mr, vcstoolnot needed
NavigationJump between dirszoxide, fzf, seshhave: zoxide + fzf-tab + tmuxinator + proj

ghq — the clone-organization leader

  • host/owner/repo layout (~/ghq/github.com/owner/repo), modeled on go get.
  • Configured via plain git-config: ghq.root, ghq.user, ghq.defaultHost; supports multiple roots (GHQ_ROOT env 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 get never searches across hosts: shorthand owner/repo resolves to ghq.defaultHost (github.com) only; GHE needs the full URL.
  • Its host/owner/repo identity 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 gh extension 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: ghq added to coreTools (next to worktrunk).
  • git.nix: ghq.root = "~/projects" (ghq expands the leading ~; git leaves the value untouched).
  • shell.nix: proj siteFunction — 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) and www. host variants were normalized to github.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 ~/worktrees gitdir 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 → both rust-lang/rustlings. mypath was a stale clean clone (no commits/changes) → deleted. rustlings (24 uncommitted exercise files) kept and migrated.
  • fresh-jlcc + jupyter-claude-code → both achhina/jupyterlab-claude-code. Both had unpushed work; fresh-jlcc (11 commits on fix/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.com

Gotchas: 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

  1. 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.
  2. Worktrunk worktrees live outside the ghq tree (~/worktrees/<repo>/<branch>, not under ~/projects/ghe.../), so a gitdir:~/projects/ghe.../ include may not cover commits from a worktree. Verify empirically (git config user.email inside a work worktree); if it falls back to personal, add a second includeIf for the worktrees path or nest worktrunk’s worktree-path under the host tree for work repos.

Sources