- TypeScript 93%
- Shell 4.9%
- JavaScript 2.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .woodpecker | ||
| bin | ||
| scripts | ||
| src | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc | ||
| .taskrc.yml | ||
| bun.lock | ||
| eslint.config.mjs | ||
| mise.toml | ||
| package.json | ||
| README.md | ||
| renovate.json | ||
| Taskfile.yml | ||
| tsconfig.json | ||
prj
A git repo registry + worktree manager. One synced registry of repos across machines, centralized bare clones
per context (work / private / …), and worktrees created anywhere on disk — with the right git identity
applied automatically via a single includeIf rule per context.
The model
$XDG_DATA_HOME/prj/
├── registry/ ← a git repo of TOML manifests (synced across machines)
│ ├── contexts/work.toml
│ └── repos/work/api-server.toml
└── repos/ ← bare clones, per-machine, cloned lazily
└── work/
└── api-server.git
- The registry holds only metadata (url, setup commands, tags) — syncing it is cheap, and a new machine clones bares lazily on first use.
- Contexts decide which folder a bare repo lives in. Git profile selection stays entirely in your dotfiles (prj never touches gitconfig).
- Worktrees live wherever you create them. Git itself is the source of truth
(
git worktree listagainst each bare) — prj keeps no shadow state.
Why this works: includeIf and linked worktrees
A linked worktree's .git file points at <bare>/worktrees/<name>, and git evaluates
includeIf "gitdir:…" against that resolved gitdir — which always lives under the bare, no matter where
the worktree directory is on disk. Verified empirically (git 2.55). So one stanza per context covers every
worktree of every repo in it:
[includeIf "gitdir:~/.local/share/prj/repos/work/"]
path = ~/.config/git/work.gitconfig
[includeIf "gitdir:~/.local/share/prj/repos/private/"]
path = ~/.config/git/private.gitconfig
Two gotchas, both checked by prj doctor:
- Git canonicalizes symlinks before matching, so the pattern must use the real path of the store
(
prj context listprints it). - The trailing
/matters — it makes git append**.
Fetch lifecycle
Branch resolution must always see the remote's current state:
- Bare clones get no usable fetch refspec from git; prj configures
remote.origin.fetch = +refs/heads/*:refs/remotes/origin/*at clone time (anddoctor --fixrepairs bares that lack it), plusorigin/HEADfor default-branch resolution. - Every
prj wtrunsgit fetch --pruneagainst the bare before resolving the branch, so a branch pushed seconds ago just works — no manual fetching inside bares. - Offline? prj warns and resolves against last-known refs.
--no-fetchskips explicitly.fetchMaxAgeSecondsin the local config adds an optional staleness window (default: always fetch).
Usage
# one-time
prj registry init --remote <url> # or: prj registry clone <url> on the next machine
prj context add work
prj repo add git@github.com:org/api.git --context work --setup "mise install" --setup "pnpm i"
# adopt existing checkouts (in place — local branches, dirty state, untracked files all survive)
prj import ~/code/some-old-checkout --context work
# daily
prj wt api feat/login # fetch → resolve → create worktree → run setup
prj wt api feat/login # same call later: jumps to the existing worktree
prj wt api new-idea --from main # new branch (confirms; --yes to skip)
prj ls # all worktrees, with dirty/↑/↓/gone status
prj wt rm api feat/login # refuses when dirty; --force to override
prj registry sync # pull --rebase + push the manifest repo
prj doctor --fix # consistency checks + repairs
prj # interactive browser (OpenTUI)
Branch resolution for prj wt <repo> <branch> is 4-way, in order: existing worktree → jump; local branch →
check out; remote branch → tracking worktree; otherwise → new branch from --from / manifest
defaultBranch / origin/HEAD.
Registry mutations (context add, repo add/remove, …) auto-commit into the registry repo with messages
like repo: add work/api — and auto-sync: after each mutation prj fetches, rebases onto the remote if
behind, and pushes (all best-effort; offline just means the commit waits locally, and prj registry status
/ prj doctor show ↑/↓ divergence). Disable with registryAutoSync = false in the local config;
prj registry sync remains the explicit escape hatch.
Importing existing checkouts
prj import <path> converts an ordinary clone into a prj-managed repo in place: its objects become the
centralized bare (a fast local clone that keeps all local branches), origin is pointed at the real URL and
fetched, and the directory itself becomes a linked worktree — uncommitted changes and untracked files are
never touched. The old .git is renamed to <path>.git-backup (stashes and reflogs stay recoverable
there); delete it once you've verified. Upstreams are re-linked for every branch that exists on the remote.
Guards: refuses detached HEADs, checkouts with stashes (--ignore-stash to override — the backup keeps
them), submodule repos, and paths that are already linked worktrees. Works offline (local refs only; the
next fetch catches up).
The registry usually lives outside every includeIf-scoped git profile, so prj configures the registry repo
locally: commits are never signed, and when no identity resolves there it falls back to a machine-tagged
bookkeeping identity (prj@<hostname> — which doubles as a record of which machine made each change).
Setup commands
setup in a repo manifest runs (in order, via sh -c) inside every new worktree, with env vars
PRJ_REPO, PRJ_CONTEXT, PRJ_BRANCH, PRJ_WORKTREE_PATH, PRJ_BARE_PATH. A failing setup keeps the
worktree; re-run with prj setup <path>.
Shell integration
A subprocess can't cd your shell. Add to .zshrc:
eval "$(prj shell zsh)"
Then prj wt api feat/login (and the UI's ⏎) drops you directly into the worktree. The same eval installs
zsh completions: commands, repo names from the registry, branch names straight from the bare's refs (no
network round-trip mid-keystroke), and context names.
Development
task install # bun install
task lint # tsc --noEmit + eslint
task test # bun test (real git fixtures in temp dirs)
task smoke # end-to-end CLI test against throwaway remotes
task build # standalone binary → dist/prj