Crosslink issues live only in SQLite (issues.db), which is gitignored. No automatic mechanism exists to keep a version-controlled snapshot in sync. The plugin approach (detecting mutation commands via static verb list) is fragile — requires manual updates per crosslink release, misses commands, and only works in OpenCode sessions.
A shell wrapper that intercepts the crosslink command, runs the original command, then conditionally triggers crosslink export after mutations. Works everywhere — terminal, OpenCode, CI, scripts.
# ~/.bashrc or equivalent shell config
# Mutation commands that require re-export
_crosslink_mutation_commands=(
issue # covers: create, edit, close, reopen, delete, link, unlink, block, unblock, label, unlabel, milestone, set-milestone, clear-milestone, tested, assign
session # covers: start, end, work, action, release
timer # covers: start, stop
sync
compact
migrate
lock
unlock
release
import
)
crosslink() {
local cmd="$1"
shift
# Execute the original command
command crosslink "$cmd" "$@"
local exit_code=$?
# If the command failed, don't trigger export
if [ $exit_code -ne 0 ]; then
return $exit_code
fi
# Check if this is a mutation command
local is_mutation=0
for m in "${_crosslink_mutation_commands[@]}"; do
if [ "$cmd" = "$m" ]; then
is_mutation=1
break
fi
done
# If mutation, trigger export
if [ $is_mutation -eq 1 ]; then
local crosslink_dir
crosslink_dir="$(find_up '.crosslink')"
if [ -n "$crosslink_dir" ]; then
local export_dir="$crosslink_dir"
# Skip export for export/import commands themselves
if [ "$cmd" != "export" ] && [ "$cmd" != "import" ]; then
command crosslink export --output "$export_dir/issues-snapshot.json" 2>/dev/null
fi
fi
fi
return $exit_code
}
# Helper: find nearest ancestor directory containing a pattern
find_up() {
local dir="$PWD"
while [ "$dir" != "/" ]; do
if [ -e "$dir/$1" ]; then
echo "$dir"
return
fi
dir="$(dirname "$dir")"
done
}.crosslink/issues-snapshot.json — already in .gitignore for .crosslink/ but we need to explicitly track it:
# .gitignore — add exception
!.crosslink/issues-snapshot.jsonOr move outside .crosslink/ to avoid .gitignore conflicts:
export_path="$crosslink_dir/../.crosslink-issues.json" # sibling to .crosslinkKeep the existing pre-commit hook as a fallback — catches any gap the wrapper misses (manual exports, CI, scripts that bypass the wrapper).
#!/usr/bin/env bash
# .git/hooks/pre-commit — export issues before commit
crosslink_dir="$(find_up '.crosslink')"
if [ -n "$crosslink_dir" ]; then
export_path="$crosslink_dir/issues-snapshot.json"
command crosslink export --output "$export_path" 2>/dev/null
if [ $? -eq 0 ] && [ -f "$export_path" ]; then
git add "$export_path"
fi
fiFor OpenCode sessions where bash tool doesn't source ~/.bashrc, inject the wrapper at session start via a hook or .bashrc equivalent. Alternatively, the wrapper can be injected via OpenCode's tool.execute.before plugin — but the shell wrapper is simpler and works everywhere.
| Case | Behavior |
|---|---|
crosslink issue create |
Wrapper runs export after create succeeds |
crosslink export |
Wrapper detects "export" and skips re-export |
crosslink import |
Wrapper detects "import" and skips re-export |
crosslink session start |
Wrapper runs export (session is mutation) |
crosslink timer start |
Wrapper runs export (timer is mutation) |
crosslink close-all |
Wrapper runs export once after all closes complete (not per-item) |
crosslink sync |
Wrapper runs export after sync completes |
| Command fails | Wrapper skips export (checks exit code) |
| Not in a crosslink repo | find_up returns empty, export silently skipped |
| Dashboard server mutations | Not covered — known gap, pre-commit hook catches |
| Multiple shells open | Each triggers export independently — no conflict (idempotent) |
- Dashboard HTTP handlers — server-initiated mutations bypass CLI. Pre-commit hook is the safety net.
- Direct SQLite writes — any code writing to
issues.dboutside crosslink CLI. Pre-commit hook catches. - Performance on bulk operations —
close-alltriggers one export after all closes, not per-item. Acceptable.
- Export is O(N) queries for N issues. For typical repos (<100 issues), this is <100ms.
- Shell wrapper adds ~5ms overhead (command lookup + subshell).
- Export runs synchronously after mutation — user sees brief pause.
- If too slow, can background:
command crosslink export ... &— but then snapshot may lag by one command.
# Test: wrapper triggers export
crosslink issue create "Test issue"
ls -la .crosslink/issues-snapshot.json # should exist and be fresh
# Test: wrapper skips on read commands
crosslink issue list
# No export triggered (no mutation command match)
# Test: wrapper skips on export command
crosslink export --output /tmp/test.json
# No double export
# Test: wrapper handles failure gracefully
crosslink issue close 99999 # non-existent issue
# Should not trigger export (exit code != 0)- Add wrapper function to
~/.bashrc - Add
.crosslink/issues-snapshot.jsonto git (tracked) - Add
.gitignoreexception for snapshot file - Run initial export:
crosslink export --output .crosslink/issues-snapshot.json - Commit snapshot file
- Verify: make a change, confirm snapshot updates