"Oh Shit Git" is what you say after the disaster. Git That Shit is what the tool does before the disaster.
Automatic pre-disaster snapshots for OpenCode AI coding agent config and state files.
Git That Shit is a safety plugin for OpenCode V2 AI coding sessions. It automatically snapshots fragile configuration and agent state files into a separate shadow git repository before risky operations.
The product exists because one malformed JSON edit, one whitespace-sensitive config mistake, one bad agent command, or
one accidental overwrite can break an entire coding environment. Git That Shit makes those failures recoverable. It is
both an OpenCode plugin and a standalone gts CLI that shares the same snapshot engine.
AI coding agents operate with elevated privileges over project files. Unlike human developers who hesitate before destructive acts, agents execute commands confidently — and sometimes catastrophically.
Why project git is not enough:
- Git is not always present
- Config files are often in
.gitignoreor untracked - Git records changes after they happen, not before
- Most users don't commit before every risky operation
Git That Shit is inspired by etckeeper — the Linux tool that automatically commits /etc to git before package
manager operations.
By default, Git That Shit tracks:
opencode.json,opencode.jsonc— OpenCode config.opencode/state/checkpoint.json,.opencode/state/handoff.md,.opencode/state/checkpoint-history.json,.opencode/state/strangerdanger-audit.jsonl— agent statepackage.json,tsconfig.json,jsconfig.json- Build configs (
vite.config.ts,webpack.config.js,rollup.config.js,eslint.config.js,prettier.config.js,biome.json,turbo.json,pnpm-workspace.yaml) - Container configs (
Dockerfile,docker-compose.yml) .env.example,.env.sample,.env.template— env templates (never real.envfiles)
- Not a replacement for project git — use project git for code
- Not a general backup system — focused on config/state files only
- Not secret management — real secrets are excluded and redacted, but this is not a vault
- Not cloud sync — local git only
- Does not copy live databases (see Interoperability)
npm install @gitdamnit/opencode-git-that-shitv0.3.0 distribution note.
v0.3.0is published as a GitHub release (v0.3.0). npm publication of0.3.0is pending, so until it landsnpm installresolves to the latest version published on npm (0.2.2). To use0.3.0immediately, install from the GitHub release source archive and runnpm run build.
Git That Shit targets OpenCode 2.0.x and depends on @opencode/plugin 2.0.21. Add it to opencode.json(c):
With options, or from a local checkout during development:
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
{
"package": "@gitdamnit/opencode-git-that-shit",
"options": {}
},
"./plugins/git-that-shit"
]
}The plugin id is gitdamnit.git-that-shit. It is a V2 plugin using
Plugin.define({ id, setup(ctx) }); the V1 @opencode-ai/plugin interface is no longer used.
V1 users: V1 plugin functions do not run in V2. See Migrating from V1 below.
Git That Shit creates an isolated shadow git repository that is completely separate from your project's .git.
| Item | Canonical location |
|---|---|
| Shadow repo | .git-that-shit/git-that-shit/snapshots/ |
| Config | .git-that-shit/git-that-shit/config.json |
| Manifest | .git-that-shit/git-that-shit/snapshots/.git-that-shit/manifest.jsonl |
| Lock | .git-that-shit/git-that-shit/snapshots/.gts.lock |
The nested git-that-shit/git-that-shit path is historical, but it is what every existing snapshot repository on disk
already uses, so it remains canonical. Older builds documented .git-that-shit/snapshots/; on first use Git That Shit
migrates such a repository to the canonical location instead of orphaning it.
Your project's git history stays clean: shadow commits never appear in your normal git log, and the shadow repo only
stores config/state snapshots.
| Trigger type | V2 mechanism | Snapshot type |
|---|---|---|
| Destructive git command | ctx.tool.hook("execute.before") |
pre-op |
| Risky package-manager op | ctx.tool.hook("execute.before") |
pre-risky-op |
| Config/file edit | ctx.tool.hook("execute.before") |
pre-edit |
| Edit tool completed | ctx.tool.hook("execute.after") |
post-edit |
| File changed out of band | ctx.event.subscribe("filesystem.changed") |
post-edit (debounced) |
| New session | ctx.event.subscribe("session.created") |
session-start |
| Context compaction | ctx.event.subscribe("session.compaction.ended") |
post-compact |
| Manual / restore | CLI or gts_snapshot / restore |
manual, pre-restore |
Pre-edit snapshots are taken before the edit runs (execute.before); post-edit snapshots are taken after the
edit completes (execute.after). A snapshot is never labelled pre-edit when it was actually taken after the edit.
Verified against OpenCode 2.0.21: post-edit snapshots are driven by
ctx.tool.hook("execute.after"). The publicfilesystem.changedevent is only emitted for out-of-band filesystem changes, not for agent tool writes, so it is registered as a debounced fallback rather than the primary post-edit signal.
The V1 plugin API was replaced by a domain-based V2 API. The hook migration is not a mechanical rename:
| V1 hook | V2 API |
|---|---|
tool.execute.before |
ctx.tool.hook("execute.before", …) |
file.edited |
ctx.tool.hook("execute.after", …) (verified: filesystem.changed does not fire for agent tool writes) |
session.created |
ctx.event.subscribe() → session.created |
session.compacted |
ctx.event.subscribe() → session.compaction.ended |
returned event hook |
ctx.event.subscribe() |
returned dispose() |
cleanup function returned from setup |
tool map / tool() helper |
ctx.tool.transform(editor => editor.add({ input: <JSON Schema>, … })) |
The gts CLI works without an active OpenCode session and uses the same engine as the plugin:
gts status # Show status
gts list # List recent snapshots
gts diff <hash> # Show what changed
gts restore <hash> # Restore (dry-run by default)
gts snapshot # Manual snapshot
gts prune # Prune old snapshots (dry-run by default)
gts config # Show configuration
gts init # Initialize the shadow repo
gts doctor # Run health checks
gts version # Show the packaged versiongts restore abc1234 # Dry-run (default)
gts restore abc1234 --yes # Restore, but skip files changed since the snapshot
gts restore abc1234 --yes --force # Restore, overwriting conflicting files
gts restore abc1234 --to-temp # Restore into a temp directory, leave the project untouchedRestore semantics:
- Dry-run (default) reports what would happen and writes nothing.
--yesrestores, but files that differ from the snapshot are treated as conflicts and left untouched.--force/--overwriteoverwrites conflicting files. Restore never silently overwrites your current work.- Redacted files are never restored. A snapshot stores
[REDACTED]placeholders instead of real secret values, so restoring them would destroy live credentials. Redacted files are skipped and reported; re-enter secrets manually. - A
pre-restorerecovery snapshot is recorded automatically before a restore (unless--force).
gts prune # Preview what would be pruned
gts prune --keep 20 # Preview keeping the newest 20
gts prune --keep 20 --yes # Actually prunePrune protects the newest keep snapshots and:
- writes a safety
git bundleof the entire shadow repo before touching anything, then - rewrites the shadow history to keep only those snapshots, atomically moving the branch, then
- rebuilds the manifest so every remaining entry references a real commit.
Because history is rewritten, surviving snapshot hashes change. The safety bundle (prune-backup-*.bundle under
.git-that-shit/git-that-shit/) is your recovery path.
Config lives at .git-that-shit/git-that-shit/config.json:
{
"version": "0.1",
"tracking": {
"include": ["opencode.json", "opencode.jsonc", ".opencode/state/*.json", "*.config.js"],
"exclude": [],
"allowSensitiveFiles": false
},
"destructiveOps": {
"riskyPackageManagerOps": true, // snapshot before npm/yarn/pnpm/bun install
"configWrites": true // snapshot before config file edits
},
"fileEdits": {
"mode": "pre-if-available", // pre-if-available | post-only | disabled
"debounceMs": 2000 // debounce for post-edit snapshots
},
"secrets": {
"redactKeys": ["token", "apiKey", "password", "secret", "credential"],
"warnOnSensitive": true
},
"snapshot": {
"maxFileSizeMb": 10, // skip files larger than 10 MB
"minDiskSpaceMb": 100, // fail if less than 100 MB free
"gitTimeoutMs": 30000, // subprocess timeout for shadow-repo git operations
"failureMode": "standard" // standard | strict (see below)
},
"restore": {
"autoPreRestoreSnapshot": true // snapshot before a restore
}
}A protective snapshot is created before risky operations, but snapshots can fail (no disk space, a locked project, repository corruption, a timeout, an unreadable file). Git That Shit never claims a snapshot exists unless the commit was verified to resolve in the shadow repository, and it never blocks a harmless command because the snapshot engine is unhealthy.
Two configurable modes (snapshot.failureMode):
| Mode | Behavior when a required snapshot cannot be created |
|---|---|
standard (default) |
The requested operation continues. A clear, credential-free warning is emitted, and no recovery point is claimed. |
strict (opt-in) |
Git That Shit attempts to block exclusively classified destructive operations (e.g. git reset --hard, git clean -fd, rm -rf). Risky package-manager operations, config writes and harmless commands are never blocked. |
In strict mode, blocking uses the OpenCode V2 permission.evaluate gate: when the protective snapshot for a specific
tool call fails, that exact tool call is denied with an explanatory message. Two honest caveats are documented in
docs/INTEROP.md:
- The
execute.beforetool hook returnsvoidand cannot veto an operation; onlypermission.evaluatecan. - The SDK does not specify the ordering of
permission.evaluaterelative toexecute.before, so blocking is best-effort. If no permission gate is exposed, strict mode fails open, logs that the operation was not blocked, and does not pretend otherwise.
Strict mode also makes the restore path fail closed: a restore whose mandatory pre-restore snapshot cannot be created is
refused (unless --force). This path is guaranteed because Git That Shit owns it.
Git That Shit treats secrets as a first-class concern:
.envfiles are excluded — real env files are never tracked- Sensitive filenames blocked —
*.pem,*.key,id_rsa,credentials.json,*.sqlite,*.dband more - Structured redaction — JSON values under secret-like keys become
[REDACTED], including deeply nested keys - Text redaction — YAML/TOML/
.env-style/.jsonc/shell files are line-redacted forkey: value,key=value,Authorization: Bearer …headers, and multiline PEM private-key blocks - Global config is always redacted — the global OpenCode config is redacted before being stored as
global-opencode/* - Fail closed — if a file cannot be safely processed (for example malformed JSON), it is skipped, never stored raw
- No secret logging — secret values never appear in logs, tool output, or errors
Git That Shit does not rewrite historical commits to remove secrets. If a secret was committed before redaction was
fixed, it remains in the shadow history. Report it and rotate the credential; see docs/SECURITY.md.
Multiple OpenCode sessions and CLI processes can operate on the same project safely:
- Every snapshot/restore/prune runs under a single project-scoped exclusive lock.
- Lock acquisition is atomic (exclusive file create), so two processes can never both hold the lock.
- Stale locks (dead process or older than 30 s) are detected and reclaimed; a process only removes a lock it can prove is stale.
- The snapshot manifest is written atomically and each entry records the originating session id.
- There is one project-wide shadow history; sessions are tracked as metadata, not separate repositories.
Git That Shit is designed to be installed and operated completely on its own. It is one of three independently developed OpenCode plugins (Git That Shit, Stranger Danger, Checkpoint Orchestrator) and it shares no code, runtime, storage, or initialization with the others.
- Works alone. It functions with no companion plugin installed.
- Works in any combination. It works alongside each other plugin, or both, in any load order.
- No ordering assumptions. Correctness never depends on another plugin running before or after it. It never requires a companion to initialize first, and a missing, disabled, failed, or outdated companion cannot prevent it from working.
- No shared framework. It depends only on
@opencode/pluginand Node built-ins. It does not import any other plugin's modules and does not require a plugin manager or shared runtime. - Read-only hooks. Its hooks never mutate the tool event (
event/event.input). They are purely additive. - Failure isolation. Snapshot failures, malformed project files, and absent companions are caught and logged; they never surface as tool failures or affect other plugins.
- Namespaced. Configuration lives at
.git-that-shit/git-that-shit/config.json, the shadow repository at.git-that-shit/git-that-shit/snapshots/, and logs are prefixed[git-that-shit].
ctx.tool.hook("execute.before", …) is awaited by OpenCode before the tool executes, so a pre-operation snapshot
completes before the risky operation runs. Verified against OpenCode 2.0.21: a deliberately delayed execute.before
hook retained the tool until the hook (including the snapshot commit) returned. (Note: the tool part's time.start is
recorded when the call is created, before hooks run; execution completes after the hook returns.)
The execute.before hook is awaited, but it cannot veto the tool: its callback returns void and there is no
documented throw-to-block contract. Pre-execution blocking therefore uses the separate permission.evaluate gate and
is only available in strict mode; see Snapshot failure behavior and docs/INTEROP.md.
| Companion state | Git That Shit behavior |
|---|---|
| Stranger Danger absent | Fully functional; redacts secrets independently |
| Checkpoint Orchestrator absent | Fully functional; snapshot history independent |
| Both absent | Fully functional |
| Companion disabled/failed | Fully functional; failures are isolated |
| Companion data malformed | Non-fatal; malformed files fail closed or are skipped |
| Configuration | Expected result |
|---|---|
| Git That Shit alone | Fully functional |
| Stranger Danger + Git That Shit | Both functional |
| Git That Shit + Checkpoint Orchestrator | Both functional |
| All three plugins | All functional |
- Checkpoint Orchestrator (migrating to SQLite): Git That Shit does not copy live SQLite databases.
.sqlite,.sqlite3,.db,*.db-wal, and*.db-shmare hard-excluded to avoid capturing a database mid-write. It may back up stable, human-readable handoff files (.opencode/state/checkpoint.json,handoff.md,checkpoint-history.json) as ordinary project files. A future integration should use Checkpoint Orchestrator's supported backup/export interface; that interface is not yet finalised, so no speculative integration code exists. Seedocs/INTEROP.md. - Stranger Danger: Git That Shit does not assume Stranger Danger's sanitization runs before it. It redacts secrets independently in its own pipeline, so snapshots are safe regardless of ordering. It never mutates tool input and never depends on another plugin's processing.
- Other plugins/agents: Git That Shit only reads project config/state files and writes its own shadow repository. It does not modify other projects, other plugins' storage, or global configuration.
The only cross-plugin interaction is read-only observation of well-known human-readable project state files for backup purposes. These files are project files, not another plugin's private storage; if they are absent or malformed, Git That Shit still works. This is a path-based, versioned-by-convention interface with no imports and no shared state.
gts doctorHealth checks include git availability, config validity, shadow repo validity, local git config, manifest readability and validity, stale-lock detection, disk space, global config presence, sensitive files, and project git independence.
The plugin registers eight tools, callable directly in OpenCode:
| Tool | Purpose |
|---|---|
gts_status |
Shadow repo path, snapshot count, last snapshot, version |
gts_list |
List recent snapshots |
gts_diff |
Diff introduced by a snapshot |
gts_restore |
Restore (dry_run/yes/force/to_temp) |
gts_snapshot |
Manual snapshot |
gts_prune |
Preview or prune (keep/yes) |
gts_config |
Effective configuration |
gts_doctor |
Health diagnostics |
All destructive tools are safe by default: gts_restore is a dry run unless dry_run:false and yes:true, and
gts_prune only previews unless yes:true.
npm run typecheck # tsc --noEmit
npm run lint # tsc --noEmit
npm run build # clean + tsc
npm test # bun testThe suite contains 322 tests across 24 files covering snapshots, snapshot-failure behavior, restore behavior and
restore security, filesystem permissions, concurrency, security, prune, CLI, packaging, independence/interoperability,
and the OpenCode V2 plugin surface (including strict-mode pre-execution blocking). Tests are hermetic and use isolated
/tmp directories and an isolated global-config directory.
Test framework: bun test.
Git That Shit is designed to be exercised by an external, Docker-based OpenCode Plugin Test Lab without any special integration framework inside the plugin:
- Runs under an isolated OpenCode V2 runtime (
opencode run --standalone/opencode serve) with independentXDG_CONFIG_HOME,XDG_DATA_HOME,XDG_STATE_HOME, andXDG_CACHE_HOME. - Works against a disposable project directory and a local mock model provider (no external network or keys).
- Registers a stable plugin id (
gitdamnit.git-that-shit), eight tools, and its hooks for automated assertions. - Owns all of its state under
.git-that-shit/, so it never contends with other plugins' fixtures. - Supports concurrency tests (serialized project lock) and the installation combinations in the companion matrix.
Docker is never required for normal operation. The test lab's code is owned separately and is not part of this repository.
- Prune rewrites history — surviving snapshot hashes change; a safety bundle is always written first.
- No cross-machine restore — snapshots are local only (git bundles are on the roadmap).
- No selective restore — restores all files in a snapshot.
- Fail-closed skips — malformed JSON config files are skipped rather than stored, because their contents cannot be proven secret-free.
- Redacted files are not restored — snapshots store
[REDACTED]placeholders rather than real secret values, so restore skips those files and reports them; re-enter secrets manually after a restore. - Post-edit signal — driven by
execute.after; thefilesystem.changedevent does not fire for agent tool writes and is used only as a fallback for out-of-band changes. - Disk space check — requires
minDiskSpaceMbfree or the snapshot fails. - Historical secrets — pre-existing secrets in old shadow commits are not rewritten automatically.
- Strict-mode blocking is best-effort — the SDK's
execute.beforehook cannot veto an operation, and the ordering ofpermission.evaluaterelative to it is unspecified. Strict mode fails open (with a loud warning) when no gate is available. Restore's strict check is guaranteed because Git That Shit owns that path. - Private storage permissions are POSIX-only — snapshot directories use
0700and files0600on Linux/macOS. Windows has no equivalent POSIX mode; permission enforcement there relies on the platform ACL model. - Symlink hardening is best-effort against a local attacker — restore refuses symlinked destinations and resolves
the nearest existing ancestor, and writes atomically via
renameso a symlink cannot be substituted between validation and writing. A determined same-user attacker racing the filesystem is still out of scope.
- Bundle export/import for portability
- Selective (single-file) restore
- Integration with Checkpoint Orchestrator's export interface
- Snapshot comparison UI
Apache-2.0
- Run
gts doctorfor diagnostics - Inspect
.git-that-shit/git-that-shit/snapshots/.git-that-shit/manifest.jsonlfor history - Review
.git-that-shit/git-that-shit/config.jsonfor configuration - See
docs/ARCHITECTURE.md,docs/SECURITY.md, anddocs/INTEROP.md

{ "$schema": "https://opencode.ai/config.json", "plugins": ["@gitdamnit/opencode-git-that-shit"] }