Skip to content

feat: PGX Git — one Git branch, one isolated Postgres - #45

Merged
alexshapalov merged 2 commits into
mainfrom
feat/pgx-git
Nov 10, 2026
Merged

alexshapalov merged 2 commits into
mainfrom
feat/pgx-git

Conversation

@alexshapalov

@alexshapalov alexshapalov commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

One Git branch. One isolated Postgres database. Automatically.

pgx git connects the Git lifecycle to PGX databases. Plain git switch gives every branch (per worktree) its own copy-on-write database, cloned from the latest validated, immutable snapshot of the main database. Main updates never replace an existing branch database, and every AI agent session on a branch gets a private copy of that branch's database.

Draft for review: not to be merged or released yet.

Review response (second commit, 3986373732)

Independent review of 6c2ccc4. Every item was addressed, each with a test where it can be tested.

Fix before merge

  • Guard false positives / negatives.
    • Redirections (2>&1, &>f, >|f) stay inside their command, and output filters (| tail -5, | grep) count as inert.
    • git checkout <x> is a switch only when Git says <x> is a ref (or with -b/-B/--orphan/--detach/-), so git checkout app/models/user.rb && bin/rails test passes.
    • if git switch x; then …; fi, time git switch x && …, ! git switch …, env A=1 git switch … and git --git-dir .git switch x && … are now refused.
    • A hook-rewritten command is recognised only by an exact parse of the rewrite, for that session or subagent, and is checked by the command it actually runs. A hand-written # pgx-routed comment no longer skips anything.
  • Shared hook directories. PGX writes only into the repository's own hooks directory. A core.hooksPath outside the repository (global, shared) or inside the work tree (husky v9's generated .husky/_, a committed .githooks/) is left alone, and the report gives the line to add by hand. pgx git uninstall never touches those directories.

Design risks (decided)

  • Path-based identity → stable ids. pgx git init writes .git/pgx-repo-id and each worktree's pgx-worktree-id. mv of the repository and git worktree move keep every mapping (tested). A copied repository, whose id names a repository that still exists elsewhere, gets a new id and its own databases (tested). Prune matches worktrees by id, and never treats a listed but missing worktree as removed.
  • Lock contention → two locks.
    • A state lock covers state changes and short database steps; it is never held across a slow copy.
    • A sync lock is held for a whole snapshot. An explicit pgx git sync of a busy main runs its pg_dump copy with only the sync lock held.
    • Automatic syncs (hooks, lazily before a clone, reset) never wait and never dump. They skip and keep the baseline when a snapshot is running. Measured: git switch -c while an explicit sync was stuck mid-copy took 285 ms.
    • Agents wait at most 60 s for the state lock, which in practice is one clone.
  • Parallel-agent gaps.
    • Agents sharing a worktree: when HEAD moves under an agent, its next command is stopped once with the reason before its database changes, and the advice is to give each agent its own worktree.
    • Non-Claude hosts: the same guard now runs inside pgx agent exec and pgx git exec for <shell> -c scripts (exit 2, nothing runs). Codex therefore gets it, and Cursor does when it uses the wrappers, which the Skill and docs now require in PGX Git repositories.
    • Cursor's raw terminal stays a documented limitation, because PGX's Cursor hooks deliberately avoid permission events.

Smaller issues

  • Recovery without pids. A snapshot in progress is dead exactly when nobody holds the sync lock (the kernel releases it on death). Branch creation runs entirely inside the state lock. No pid is consulted, so pid reuse is harmless.
  • Read-only lock enforced. A snapshot whose ALLOW_CONNECTIONS false fails is not published (new lock failpoint test).
  • Unsettled main. Automatic snapshots happen only when main is idle, exists, and has no unapplied migrations. Otherwise the baseline is kept and the hook says why (tested with a held connection).
  • Reset leak. pgx git reset records an intent: move the original aside, clone the fresh copy, swap. After a failure or a crash it is undone, never completed, so no data is lost and no _stg_ database is left behind (tested with an injected failure and a SIGKILL mid-reset).
  • Main branch disagreement. The engine's recorded main branch wins over a checked-out .pgx.toml for hooks, reset and prune (tested).

CI for 6c2ccc4 was green on all platforms (checked through the API; the checks page shown during the review did not list the CI jobs).

Developer experience

cd my-project
pgx git init
#  ✓ Git repository detected
#  ✓ PGX runtime ready
#  ✓ Main database configured
#  ✓ Main snapshot v1 ready
#  ✓ Git integration installed
#  ✓ Automatic database branching enabled
#  PGX Git is ready. Every Git branch gets its own Postgres.

git switch -c feature/auth
#  ✓ PGX database ready: feature/auth
#    Cloned from main (v2) in 35 ms
#    Database: pgx_myapp_auth_dd186b

pgx git exec -- bin/rails db:migrate      # runs against feature/auth's database
git switch main                           # main's database again; feature/auth keeps its changes
pgx git sync                              # ✓ published main snapshot v3 (previous v2 kept)
git switch -c feature/billing             # starts from v3
pgx git status
$ pgx git ls
BRANCH           DATABASE                  BASE    STATUS      WORKTREE
main             pgx_myapp_main_cefdf8     v3      ready       -
feature/auth     pgx_myapp_auth_dd186b     v2      stale       myapp
feature/billing  pgx_myapp_billing_8b1554  v3      current     myapp

Main snapshots: v3 (current), v2 (previous), v1 (retired)

Commands: pgx git init | status | ls | sync | env | exec | reset --from-main | prune | doctor | uninstall | shell | install-shell, plus the internal pgx git hook. All of them accept --json.

Architecture

main Git branch ─► main development database       (migrated/seeded as usual)
                       │  pgx git sync · post-merge on main · lazily before a new clone
                       ▼  fingerprint changed? (schema catalogs + migration bookkeeping)
                 staging copy ─► validate ─► VACUUM FREEZE ─► rename + ALLOW_CONNECTIONS false
                       ▼
                 main snapshot vN (immutable) ◄── new Git branches clone from here
feature/auth ─ pgx_myapp_auth_…          developer's branch database (baseline v2)
                 ├─ agent-claude-<A>      each agent session…
                 ├─ agent-codex-<C>
                 └─ agent-claude-<A>-<s>  …and each subagent: its own copy

New module crates/bin/pgx/src/cmd/git/:

Module Role
config [git], [git.cleanup], [git.agents], [git.migrations] in .pgx.toml. Same tiny TOML subset as [agent], every value validated. A .pgx.toml without [git] still loads.
repo Git plumbing (argv only): top level, common dir, worktree id, HEAD (branch / detached / rebasing), hooks path, reflog rename detection
naming pgx_<repo>_<branch>_<hash>: [a-z0-9_], ≤ 63 bytes, hash over repo id + worktree id + full branch + role; collisions fall back to a 12-hex name
state $PGX_HOME/git/<repo_id>/state.json (0600, atomic rename) under a kernel flock (no stale locks). Intents carry the owner pid for crash recovery.
fingerprint schema/migration fingerprints (a few catalog queries); pending migrations = committed files at main's Git revision vs bookkeeping rows
core snapshot manager (sync_locked), branch resolution (resolve_head), recovery, main database creation/repair, agent binding
env connection variables, --shell exports, prompt integration for bash/zsh/fish, rc-file block
hooks non-destructive hook installation and removal
guard the same-command branch-switch guard for agent shell commands
cmds the commands

Key behaviours:

  • Atomic snapshots. A staged copy is a clone when main is idle, or a pg_dump → pg_restore copy when main is in use. Connections are never terminated, and a busy database is never physically copied. The copy's fingerprint must match main's before it is published. Any failure drops the staging copy and keeps the previous snapshot. Interrupted syncs (killed process, engine restart) are rolled back on the next command. A snapshot or main database dropped by hand is repaired: main is restored from the snapshot, never republished empty.
  • Git revision ≠ database state. Migrations committed on main but not applied to the main database are reported as pending. A configured migration command runs only on pgx git sync --migrate (argv, no shell). Unattended runs need auto_apply_main plus a prior approval of that exact text.
  • Existing branches are preserved. They are marked stale; pgx git reset --from-main is explicit, asks first (or --force), and refuses main and protected branches.
  • Worktrees. Branch databases are keyed by worktree. Linked worktrees (.git file) and core.hooksPath are handled, and one hook install covers every worktree. A detached HEAD gets a per-worktree database; during a rebase the branch keeps its own.
  • Hooks. A missing hook gets a PGX-owned script. An existing shell hook gets a marked block inserted after its shebang: lines, permissions and stdin are untouched, and sh -e is safe. Hooks committed to the repo, symlinked hooks, and hooks in other languages (Ruby, Python, …) are skipped, with the line to add by hand. So are hooks directories that are shared or inside the work tree (husky). The hook never blocks Git.
  • Locks and identity. A state lock (short sections only) and a sync lock (one snapshot) coordinate concurrent operations; recovery uses lock liveness, not pids. Repository and worktree ids live in id files in the Git directory, so they survive moves.
  • Environment, honestly. A hook cannot change the parent shell's environment, and PGX never claims it did. Instead: pgx git exec -- <cmd> (execs the command: its exit codes and signals are kept), eval "$(pgx git env --shell zsh)", or the opt-in pgx git install-shell prompt hook, which restores the previous environment when the shell leaves the repo. Fail closed: exec refuses to run, and the shell gets an unreachable sentinel DATABASE_URL, never the inherited one.
  • Credentials. No URL or password goes into .pgx.toml, hooks, logs or state files. pgx git env masks the password in human and --json output unless --reveal is given.

AI agents

  • With [git.agents] enabled (the default), an agent session's branch is cloned from its current Git branch's database. The session key gains a Git scope (SessionKey.scope, AgentMeta.git_scope/git_branch, both optional). With scope = None everything is byte-identical to before, so existing records and non-Git projects are unaffected.
  • Every session and every subagent gets its own database. They never share with each other and never write to the developer's branch database.
  • A busy branch database (a dev server is connected) is copied logically. On main, agents branch from the latest snapshot.
  • If an agent switches Git branches, its next database is a branch of the new Git branch's database. In Claude Code the parent session's Bash commands are rerouted automatically. SessionEnd/pgx agent down ends the session's branches for every Git branch it used.
  • Same-command switches are refused. A command's DATABASE_URL is fixed when it starts, so git switch x && bin/rails db:migrate would migrate the old branch's database. Claude Code's PreToolUse hook denies such commands before they run, for the parent session and subagents alike. This covers git switch, git checkout <ref>, gh pr checkout, and cd into another worktree, followed by more commands. The agent is told the two safe forms: switch in one call and work in the next, or git switch x && pgx git exec -- <cmd>, where the wrapper resolves after the switch.
  • pgx git exec / pgx git env inside an agent session return the session's own branch.
  • Without [git], pgx agent behaves exactly as before. pgx branch and pgx clone are unchanged.

Benchmarks

Real measurements: macOS arm64 (M1 Pro, APFS), engine 0.1.2 dist, release build. Printed by tests/git.rs --nocapture and the hand-run workflow above; numbers are not hardcoded.

Operation Measured
Branch database clone (from snapshot) 34–72 ms
git switch -c including the hook (clone + mapping + output) 486–589 ms
git switch to an existing branch including the hook 230–245 ms
pgx git sync, idle main (clone + validate + freeze + publish) 204–292 ms (whole command ≈ 0.5 s)
pgx git sync when main is unchanged fingerprint only, no copy
pgx git init, new project 0.90–1.14 s
git switch -c while an explicit sync is stuck mid-copy 285 ms (automatic sync skipped, branch from the current baseline)
pgx git exec -- psql … (resolve + exec) 290–380 ms
3 concurrent agent sessions on one Git branch 589–610 ms total
4 worktrees × 2 simultaneous first resolutions + 2 forced syncs 1.37–1.64 s total, one database per worktree

Tests

  • Unit tests: 158 (pgx binary). New tests for config validation (hostile values), branch-name rules, naming bounds and collisions, state round-trip and lock, fingerprints, shell quoting, hook block inertness (including sh -e), the branch-switch guard, and argument parsing.
  • tests/git.rs: 21 black-box tests (20 in the release configuration, where the fault-injection test is replaced by one proving PGX_GIT_FAILPOINT is ignored) with real temporary Git repositories, real hooks fired by real git switch, and a real runtime:
    • A new, existing, .pgx.toml/template and repeated init; no repo; external import schema-only by default
    • B/C/D branch creation, switching, persistence
    • E main sync: new branches get the new schema, existing branches keep theirs; pending migrations; --migrate
    • F injected copy/validate/publish failures keep the previous snapshot
    • G worktrees and repositories never share databases
    • H parallel Claude/Codex sessions and subagents get separate databases; a busy source is copied logically; Git-scoped teardown
    • H2 in-session branch switch is rerouted; same-command switch is denied; the wrapper form re-resolves
    • I existing hooks, permissions and stdin preserved; uninstall byte-identical; core.hooksPath
    • J prune touches only abandoned PGX Git databases
    • K concurrent hooks and syncs; crash plus engine restart mid-staging
    • L bash/zsh/fish env and prompt integration, exec exit codes and signals, JSON redaction and --reveal
    • M hostile and long branch names, name collisions, inherited DATABASE_URL, unsafe config
    • N repairs of dropped snapshot and main; non-shell hooks; branches whose .pgx.toml predates [git]
    • O1–O5 (review): shared and husky hook directories untouched; moved repository and git worktree move keep databases, a copy gets its own; automatic sync skips a busy main and never blocks behind a running snapshot; reset is undone after a failure or SIGKILL; wrapper guard for every host; recorded main branch wins
    • a test that release builds ignore PGX_GIT_FAILPOINT
  • Every existing suite still passes: agent 16, agent_exec 8, agent_hook 11, agent_install 12, agent_subagent 12, branch 8, clone 7, copy 18, databases 10, doctor 7, lifecycle 27, run 8, ttl 10.
  • Skill: skills/tests/pgx/run.sh passes; e2e.sh 39/39 (npm skills CLI part skipped offline); plugins/sync.sh --check passes.
  • cargo fmt --check and cargo clippy --all-targets -D warnings are clean with and without --features failpoints (local rustc 1.92; CI's 1.96 will confirm).
  • CI gains a "PGX Git suite" step that runs --test git with --features failpoints and then in the release configuration.

Docs

New docs/pgx/local-dev/git.md (guide, .pgx.toml reference, Rails, Prisma, Django, Node, worktree, Claude Code, Codex and Cursor examples, limits). README, cli.md, agents.md, the PGX Skill (SKILL.md and the new references/git-integration.md), detect-project.sh (git.repository / git.pgx_git) and the plugin README are updated. The plugin copy of the Skill is re-synced.

Remaining limitations

  • Snapshotting a busy main, or branching a busy feature database for an agent, needs pg_dump/pg_restore. Without them PGX refuses and explains rather than terminating connections.
  • Pending-migration detection knows the Rails, Prisma, Django and golang-migrate/dbmate layouts. Other tools get schema-fingerprint detection only.
  • The same-command guard reads the command text, so a branch switch inside a script or behind a variable is not detected.
  • Cursor's raw terminal keeps the session's first database after a branch switch, because PGX's Cursor hooks deliberately stay off permission events. In PGX Git repositories, Cursor should run database commands as pgx git exec -- …; the Skill and docs say so.
  • Agents sharing one worktree share its HEAD. They are warned once when it moves, but separate worktrees are the real fix.
  • Running servers keep their environment after git switch; restart them.
  • Fish integration is implemented but was not exercised: fish isn't installed on the machine the tests ran on. Linux and ZFS run in CI only.
  • The Skill still gates on PGX 0.1.2 and tells agents to check that pgx git exists. A release bump is needed before users get the command (intentionally not part of this PR).

🤖 Generated with Claude Code

alexshapalov and others added 2 commits November 9, 2026 18:46
Add the `pgx git` command group (init, status, ls, sync, env, exec,
reset --from-main, prune, doctor, uninstall, shell, install-shell, hook):

- every Git branch, per worktree, gets a copy-on-write database cloned
  from the latest validated, immutable main snapshot; plain `git switch`
  creates or reuses it through post-checkout/post-merge/post-rewrite
  hooks that are inserted non-destructively into existing hooks
- main snapshots: schema + migration-bookkeeping fingerprints, staging
  copy (clone when idle, pg_dump when busy), validation, atomic rename
  and ALLOW_CONNECTIONS false; failures keep the previous snapshot;
  interrupted syncs are rolled back; pending migrations are reported
- existing branch databases are never replaced; stale branches are
  shown and reset only explicitly
- honest environment handling: `pgx git exec`, `pgx git env --shell`,
  an opt-in prompt integration for bash/zsh/fish; fail-closed sentinel;
  credentials redacted in human and JSON output unless --reveal
- agents: in a PGX Git repository each session and subagent branches
  from its current Git branch's database (session key gains a Git
  scope); busy sources are copied logically, never terminated; Claude
  Code sessions that switch branches are rerouted, and a command that
  switches branches and then uses the database is refused before it runs
- fault injection for the failure tests only with --features failpoints

Tests: tests/git.rs (16 black-box scenarios with real repositories,
hooks, worktrees, agents, crash recovery), unit tests per module; docs
docs/pgx/local-dev/git.md; Skill and plugin updated; CI runs the suite
with and without failpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… recovery)

Branch-switch guard
- redirections (`2>&1`, `&>f`, `>|f`) stay inside their command; output
  filters (`| tail`, `| grep`) count as inert
- `git checkout <x>` is a switch only when Git says <x> is a ref (or with
  -b/-B/--orphan/--detach/-); file restores pass
- shell keywords and launchers (`if … then`, `!`, `time`, `env`, `nohup`)
  and Git global options with values (`--git-dir`, `--work-tree`, …) are
  seen through
- a hook-rewritten command is recognised only by an exact parse, for that
  session or subagent, and is checked by the command it runs
- the same check runs inside `pgx agent exec` / `pgx git exec` for
  `<shell> -c` scripts, so non-Claude hosts get it too (exit 2)
- agents sharing a worktree: when HEAD moves under an agent, its next
  command is stopped once with the reason before its database changes

Hooks
- only the repository's own hooks directory is written; a shared
  core.hooksPath or one inside the work tree (husky's `.husky/_`) is left
  alone with the manual line; uninstall never touches them

Identity
- repository and worktree ids are written to `.git/pgx-repo-id` and each
  worktree's `pgx-worktree-id`: mv and `git worktree move` keep every
  mapping, a copied repository gets its own id; prune matches worktrees by id

Locking and snapshots
- a separate sync lock; the state lock is never held across a slow copy
  (explicit sync takes the pg_dump copy with only the sync lock)
- automatic syncs (hooks, before a clone, reset) never wait and never
  dump: they skip, keeping the baseline, when a snapshot is running, main
  is busy or missing, or migrations are pending
- recovery without pid checks (sync-lock liveness / state-lock invariant)
- a snapshot that cannot be made read-only is not published
- `pgx git reset` records an intent and is undone after a failure or crash
- the engine's recorded main branch wins over a checked-out .pgx.toml

Tests: guard unit tests; integration tests for shared/husky hook dirs,
moved/copied repositories and worktrees, non-blocking automatic sync,
crash-safe reset, wrapper guard, and the lock failpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alexshapalov
alexshapalov marked this pull request as ready for review November 10, 2026 06:30
@alexshapalov
alexshapalov merged commit d004189 into main Nov 10, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant