No description
  • TypeScript 93%
  • Shell 4.9%
  • JavaScript 2.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Morten Olsen f683eef238
All checks were successful
ci/woodpecker/push/compliance Pipeline was successful
ci/woodpecker/push/draft-release Pipeline was successful
ci/woodpecker/push/quality Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
bin install command
2026-09-14 19:58:10 +02:00
.woodpecker ci: woodpecker pipelines — shared bun taskfile, quality/compliance/renovate gates, drafted releases with cross-compiled binaries 2026-09-14 14:47:35 +02:00
bin feat: initial implementation 2026-09-14 12:33:58 +02:00
scripts fix: fetch foreign-platform opentui natives for cross-compilation (incl. musl variants bun resolves statically) 2026-09-14 14:51:08 +02:00
src feat: import existing checkouts, registry auto-sync, zsh completions 2026-09-14 13:00:38 +02:00
.gitignore feat: initial implementation 2026-09-14 12:33:58 +02:00
.prettierignore feat: initial implementation 2026-09-14 12:33:58 +02:00
.prettierrc feat: initial implementation 2026-09-14 12:33:58 +02:00
.taskrc.yml ci: woodpecker pipelines — shared bun taskfile, quality/compliance/renovate gates, drafted releases with cross-compiled binaries 2026-09-14 14:47:35 +02:00
bun.lock feat: initial implementation 2026-09-14 12:33:58 +02:00
eslint.config.mjs feat: initial implementation 2026-09-14 12:33:58 +02:00
mise.toml ci: woodpecker pipelines — shared bun taskfile, quality/compliance/renovate gates, drafted releases with cross-compiled binaries 2026-09-14 14:47:35 +02:00
package.json feat: initial implementation 2026-09-14 12:33:58 +02:00
README.md feat: import existing checkouts, registry auto-sync, zsh completions 2026-09-14 13:00:38 +02:00
renovate.json ci: woodpecker pipelines — shared bun taskfile, quality/compliance/renovate gates, drafted releases with cross-compiled binaries 2026-09-14 14:47:35 +02:00
Taskfile.yml bin install command 2026-09-14 19:58:10 +02:00
tsconfig.json feat: initial implementation 2026-09-14 12:33:58 +02:00

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 list against 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 list prints 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 (and doctor --fix repairs bares that lack it), plus origin/HEAD for default-branch resolution.
  • Every prj wt runs git fetch --prune against 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-fetch skips explicitly. fetchMaxAgeSeconds in 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