
Company News
Socket Joins New OpenJS Program to Fund Node.js Security Work
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.
IngotVault — spare remotes for a workspace of Git repos: push all local branches and tags to bare mirrors on a drive you control. Never touches origin.
Every commit in a second place you control.
A spare remote for a folder of Git repos: push committed history into bare mirrors on a drive you control. Never touches origin.
Site: ingotvault.dev. Install, Safety, and the agents write-up live there. This README is the CLI / config reference. Keep both in sync when behavior changes.
| On the site | In this repo |
|---|---|
| Install | Quick start + full CLI below |
| Safety | Guarantee summary below (same facts) |
| An undo layer for autonomous edits | Working with coding agents |
| — | docs/encryption.md, config.example.json |
Every commit you've made lands in a second place you control. Not uncommitted work (unless you opt in), not LFS objects. Default push is git push --all, --tags, plus refs/notes/*, refs/replace/*, and refs/ingotvault/*. Optionally a snapshot of your dirty tree (captureWorktree). Custom namespaces (e.g. Gerrit refs/changes) are not covered.
That gap exists even if you already have a forge and a file backup:
main is on originUnlike a file copy of a live .git, a push into a bare mirror is a git operation: validated on receipt, atomic at the ref level, restorable with git clone, checkable with ingotvault verify. An unplugged drive won't replicate your rm -rf the way cloud sync can. "Git is already distributed" only helps if another up-to-date clone exists. Often the laptop is the sole copy.
Not for one or two repos you already push everywhere with no unpushed branches.
The product is the guarantee set below: what a late-night bash loop usually gets wrong. Readable form: Safety on the site.
| Concern | Behavior |
|---|---|
origin / other remotes | Never modified |
| What gets pushed | All local branches + tags + refs/notes/* + refs/replace/* + refs/ingotvault/* (WIP under refs/ingotvault/wip/…, preforce rescues under refs/ingotvault/preforce/…) |
| Force update | Never by default. Requires allowForceWithLease: true in config and CLI --force-with-lease with --repo or --all-repos. Uses ls-remote tips and explicit --force-with-lease=<ref>:<oid>; if a mirror tip is missing locally, fetches it into refs/ingotvault/preforce/… first, then pushes refs/ingotvault/* to the spare remote |
| Non-fast-forward (rebase/amend) | Fail that repo with DIVERGED: (branches and tags); other repos continue. Do not delete the stale mirror — see Divergence |
Existing backup with wrong URL | Fail that repo; continue others |
| Concurrent runs | Lock file mirrorRoot/.ingotvault.lock (records hostname; foreign PIDs are not probed) |
| Mirror volume missing/locked / not a vault | Exit 2 (scheduled: expected skip). init writes mirrorRoot/.ingotvault-vault; normal runs never mkdir the tree — an unplugged /Volumes/… must not silently create mirrors on the boot disk |
| Deleted local branches | Not pruned from the mirror — intentional ratchet: history only accumulates (valuable when an agent "cleans up" a branch) |
| Uncommitted work / stashes | Not covered by default. Opt-in captureWorktree / --capture-worktree snapshots dirty trees (incl. untracked that are not gitignored) to refs/ingotvault/wip/<host>/… without mutating the worktree; keeps newest wipRetention (default 20) per worktree slug on this host. Prune/push is host-scoped so a shared vault does not wipe another machine's WIP. That rolling window is the only thing IngotVault deletes from a mirror. Use wipExclude for extra pathspecs |
| Git LFS | Not covered — bare push stores pointer files only; warned when .gitattributes has filter=lfs |
| Linked worktrees | Discovery skips dirs whose .git is a file, but their branches live in the parent repo — push --all from the parent already covers committed work. With captureWorktree, dirty state is snapshotted for each path from git worktree list |
| Submodules | Skipped (.git is a file). Parent stores only the gitlink SHA; submodule objects are not pushed. Restore needs each submodule's own remote (or its own IngotVault mirror) |
| Drive pulled mid-push | Push may be partial; remount and re-run — Git usually recovers; verify helps confirm |
Requires Node.js 22+ and git on PATH (git --version is checked at startup). Same steps: Install on the site.
pnpm add -g ingotvault
# or: npm i -g ingotvault
From a clone (dev builds):
git clone https://github.com/Catalyst-Forge-LLC/ingotvault.git
cd ingotvault
pnpm install
pnpm run build
pnpm link --global
ingotvault init
# prompts for workspace + mirror roots; writes ./ingotvault.config.json
# and creates mirrorRoot/.ingotvault-vault (required on every later run)
ingotvault list # discover repos → planned mirror paths
ingotvault --dry-run # no writes
ingotvault # ensure bare mirrors + backup remote + push
ingotvault verify # compare local branch/tag tips to mirror tips
ingotvault relink # after moving mirrorRoot: retarget the backup remote
Sample list output:
ingotvault workspace=/Users/you/code mirror=/Volumes/Backup/git-mirrors
Found 3 repo(s)
acme/widgets -> /Volumes/Backup/git-mirrors/acme/widgets.git
notes -> /Volumes/Backup/git-mirrors/notes.git
tools/scripts -> /Volumes/Backup/git-mirrors/tools/scripts.git
User-wide config (instead of cwd):
ingotvault init --global --workspace ~/code --mirror /Volumes/Backup/git-mirrors
Resolution order (first found wins):
--config <path>./ingotvault.config.json~/.config/ingotvault/config.json (Windows: %APPDATA%\ingotvault\config.json)workspaceRoot and mirrorRoot are required. ~ is expanded. See config.example.json (includes "$schema" for editor autocomplete) and schema/ingotvault.config.schema.json.
Mirror naming (path-tree): the relative workspace path is preserved under mirrorRoot, with .git appended — e.g. acme/widgets → acme/widgets.git. Nested paths avoid collisions that flat renaming would create (a/b-c vs a-b/c).
If two machines share one vault drive and the same relative repo paths, they write into the same bare mirrors. Scope mirrorRoot per machine (e.g. …/git-mirrors/laptop/) unless you intentionally want a shared spare.
git clone /Volumes/Backup/git-mirrors/acme/widgets.git widgets-restored
cd widgets-restored
That checks out the mirror's default branch. After each successful push, IngotVault sets bare HEAD from origin/HEAD when present, otherwise main/master / init.defaultBranch, and only then the current branch — so a push while you're on a feature branch does not flip the clone default. Other branches exist as origin/<name> until you git checkout <name> (or git switch <name>).
Uncommitted work is not covered unless you opt into --capture-worktree (see Working with coding agents and Safety).
Restore a WIP snapshot (fetch from the mirror first if needed):
git fetch backup 'refs/ingotvault/wip/*:refs/ingotvault/wip/*'
git restore --source=refs/ingotvault/wip/<host>/<slug>/<timestamp> --worktree --staged .
# inspect only (does not write the worktree):
git show refs/ingotvault/wip/<host>/<slug>/<timestamp>
By default IngotVault never force-pushes. After a rebase or amend, the bare mirror may reject updates. That repo fails with a loud DIVERGED: message (including moved tags) while other repos continue.
The stale mirror may be the only copy of pre-rebase history. Do not delete it. Same steps: Safety → Divergence recovery.
Option A — aimed force update (keeps missing mirror tips under refs/ingotvault/preforce/…, then pushes refs/ingotvault/* to the spare remote):
ingotvault --force-with-lease --repo notes
Option B — quarantine the old mirror and start fresh (prefer _diverged/ so the live tree stays clean):
mkdir -p /Volumes/Backup/git-mirrors/_diverged
mv /Volumes/Backup/git-mirrors/notes.git \
/Volumes/Backup/git-mirrors/_diverged/notes.diverged-2026-08-11.git
ingotvault --repo notes
Git does not encrypt repositories at rest. Anyone who can mount mirrorRoot can read every bare mirror. Encrypt the volume (or an encrypted container on it), then point mirrorRoot inside that unlocked path.
Unlock the volume before running ingotvault. If the path is missing or locked, IngotVault exits with code 2. For scheduled runs, treat exit 2 as an expected skip (vault unplugged/locked).
| OS | Typical option |
|---|---|
| Windows | BitLocker To Go on the removable drive |
| macOS | APFS encrypted volume or encrypted disk image |
| Linux | LUKS (cryptsetup) |
| Cross-platform | VeraCrypt container |
Step-by-step OS setup: docs/encryption.md. Short version also on Install.
Removable volumes are often exFAT/FAT. Git may report “dubious ownership”. With safeDirectory: "per-mirror" (default), IngotVault adds each mirror path via git config --global --add safe.directory <path> (writes ~/.gitconfig / the global gitconfig). Set "safeDirectory": "off" to disable.
Inspect or remove only entries under your mirrorRoot:
ingotvault safe-dirs --list
ingotvault safe-dirs --clean
Or selectively by hand:
git config --global --get-all safe.directory
git config --global --unset safe.directory '/Volumes/Backup/git-mirrors/notes.git'
Do not use --unset-all safe.directory — that deletes unrelated entries you may have added for other drives.
workspaceRoot (encrypt that disk too, or commit / use --capture-worktree before you care); recovery keys stored on the same media; Git LFS object bytes; submodule object stores (see Safety).Agents tend to fail by rewriting history (rebase, amend, reset --hard, deleting a "stale" branch), not by quietly losing whole directories. An append-only spare remote that never force-pushes and never prunes deleted branches is a ratchet: the mirror still has the branch the agent removed.
Longer argument: An undo layer for autonomous edits.
The usual agent-shaped loss is uncommitted work (checkout ., clean -fd, hard reset on a dirty tree). Enable WIP capture at session boundaries:
ingotvault --capture-worktree
# or "captureWorktree": true in config
.gitignore is respected (git add -A on a temporary index). Ignored secrets stay out of the mirror; set wipExclude for additional pathspecs. WIP and preforce tips live under refs/ingotvault/*, which is pushed to the spare remote with the rest of each run.
ingotvault verify is a post-session detector: diverged on a repo you did not rebase yourself means something rewrote history. Use --quiet-if-clean in harness hooks so a clean vault stays silent.
Blast radius: if the vault is unlocked and writable while an agent has a shell, the agent can destroy mirrors or invoke --force-with-lease. Mitigations are OS/process, not this tool:
ingotvault from the agent's shell allowlistsafeDirectory: "per-mirror" mutates global ~/.gitconfig — relevant in sandboxesworkspaceRoot up to maxDepth (default 3).excludeDirNames (default includes node_modules, .git, .hg, __ARCHIVE)..git is a directory (linked worktrees and submodules with a .git file are skipped as scan roots; see Safety for branch/WIP coverage).push --all; verify reports when there are no local branches.ingotvault init [--global] [--workspace <path>] [--mirror <path>]
[--remote-name backup] [--max-depth 3]
ingotvault [run] [--config <path>] [--repo <path|name>] [--dry-run]
[--verbose] [--scheduled] [--capture-worktree]
[--force-with-lease --repo <path>|--all-repos]
ingotvault list [--config <path>] [--repo <path|name>]
ingotvault verify [--config <path>] [--repo <path|name>]
[--verbose] [--quiet-if-clean]
ingotvault relink [--config <path>] [--repo <path|name>]
ingotvault safe-dirs [--config <path>] [--list|--clean]
relink updates a mismatched backup remote URL to the current path-tree mirror location. If the old URL still points at a bare repo on disk (e.g. flat foo-bar.git from an earlier naming style) and the new path is empty, it moves that bare repo first so history is preserved, then git remote set-url. Use --dry-run to preview.
--repo matches the workspace-relative path (preferred), a unique path suffix, or a unique basename. If several repos share the same leaf name, the command fails and asks for the full relative path.
--scheduled writes a timestamped log under logDir and prunes logs older than logRetentionDays (default 30; 0 = keep forever). No interactive pause; pair with your OS task scheduler. Prefer not alerting on exit 2.
ingotvault verify compares each local branch tip to the bare mirror and reports ok, behind (mirror tip is an ancestor, N commits behind), diverged, or missing-mirror. Diverged refs are never counted as "behind." Ends with a summary (12 ok, 2 diverged, …). --quiet-if-clean prints nothing and exits 0 when everything matches.
Same table as Safety → Exit codes:
| Code | Meaning |
|---|---|
0 | Success (verify with --quiet-if-clean stays silent when clean) |
1 | Setup/config/CLI error (bad config, missing git, ambiguous --repo, lock held, --force-with-lease without --repo/--all-repos) |
2 | Mirror root unavailable (missing marker, locked, or not writable) — expected skip for scheduled runs; prefer not alerting |
3 | One or more repos failed on run, or verify found drift (behind / diverged / missing-mirror) |
git push and stay updatable in place.backup remote → push branches/tags/notes/replace/refs/ingotvault/* into bare mirrors on a path you choose, with the guarantee set above.pnpm run build
pnpm pack # sanity-check tarball
pnpm publish # logs in if needed; patch-bumps if this version is on npm
Site: pnpm ship from the repo root (FilePress via npm getfilepress). See site/README.md.
Apache-2.0
FAQs
IngotVault — spare remotes for a workspace of Git repos: push all local branches and tags to bare mirrors on a drive you control. Never touches origin.
We found that ingotvault demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Company News
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.

Security News
Two compromised GitHub Actions were re-enabled with malicious tags intact, exposing thousands of downstream repositories to Mini Shai-Hulud.

Research
/Security News
A malicious Firefox extension fetches its payload after installation to evade detection, steal Google session cookies, and automate account takeover.