Distributed issue tracking using Git's native data model.
Command: git issue (simple to use, despite the project name)
- The Problem
- The Solution: Issues Are Just Git
- Installation
- Commands
- Platform Bridges (GitHub, GitLab, Gitea, Forgejo)
- AI Agent Workflows
- Distributed Merge
- How It Works: The Data Model
- The Format Spec
- Design Decisions
- Prior Art
- Running Tests
Your source code travels with git clone. Your issues don't.
Migrate from GitHub to GitLab? Your code comes with you. Your issues stay behind, trapped in a proprietary API. Work offline on code? Sure. Work offline on issues? Not without a web browser and internet connection. Linus Torvalds called this out in 2007:
"A 'git for bugs', where you can track bugs locally and without a web interface."
-- Linus Torvalds, LKML 2007
Nearly two decades later, this problem remains unsolved.
Here's the insight: issues are append-only event logs, and Git is a distributed append-only content-addressable database. The data model fits perfectly.
git-issue stores issues as Git commits under refs/issues/. No
external database. No JSON files in the working tree. No custom merge
algorithms. Just commits, trailers, and refs -- Git's own primitives:
- Commits = issue events (creation, comments, state changes)
- Refs = issue identity (one ref per issue, named by UUID)
- Trailers = structured metadata (State, Labels, Assignee, Priority)
- Merge commits = distributed conflict resolution (built into Git)
- Fetch/push = synchronization (no custom protocol needed)
Git already solved distributed synchronization, content addressing, cryptographic integrity, and three-way merging. Why rebuild all that for issue tracking?
$ git issue create "Fix login crash with special characters"
Created issue a7f3b2c
$ git issue ls
a7f3b2c [open] Fix login crash with special characters
b3e9d1a [open] Add dark mode support
$ git issue comment a7f3b2c -m "Reproduced on Firefox 120 and Chrome 119"
Added comment to a7f3b2c
$ git issue state a7f3b2c --close --fixed-by abc123
Closed issue a7f3b2c
Sync issues with any platform, or push/fetch raw refs:
$ git issue sync github:owner/repo
# Or use raw Git refs
$ git push origin 'refs/issues/*'
$ git fetch origin 'refs/issues/*:refs/issues/*'
Most issue trackers bolt a database onto version control. git-issue
realizes that Git already is a distributed database -- one that's
designed exactly for this problem.
| Issue Tracking Concept | Git Primitive | Why It Works |
|---|---|---|
| Issue identity | refs/issues/<uuid> |
Unique, immutable, collision-free in distributed systems |
| Issue events | Commits in a chain | Append-only, content-addressed, cryptographically verified |
| Metadata | Git trailers | Parseable by standard Git tools (interpret-trailers) |
| Comments | Commit messages | Full-text searchable with git log --grep |
| State history | Commit ancestry | git log refs/issues/<id> shows the full timeline |
| Distributed sync | git fetch/push |
Zero custom protocol needed |
| Conflict resolution | Three-way merge | Merge commits resolve divergent issue updates |
| Data integrity | SHA-1/SHA-256 | Tampering detection built into Git |
| Offline work | Local refs | Full read/write access without network |
| Atomic operations | Ref updates | git update-ref is atomic, no race conditions |
By using Git's data model, git-issue inherits decades of battle-tested
distributed systems engineering:
- ✅ Content-addressable storage -- Issues are deduplicated, cryptographically verified
- ✅ Three-way merge -- Divergent updates resolve deterministically
- ✅ Atomic ref updates -- No race conditions when multiple processes modify issues
- ✅ Efficient transfer -- Git's packfile protocol minimizes bandwidth
- ✅ Protocol v2 support -- Server-side filtering for repos with 10,000+ issues
- ✅ SSH/HTTPS transport -- Same authentication as code pushes
- ✅ Clone/fork/mirror -- Issues travel with code automatically
- ✅ Garbage collection -- Unreachable issues are cleaned up by
git gc
This isn't "using Git as a database". This is recognizing that issue tracking is distributed synchronization of append-only logs, which is exactly what Git was designed to do.
brew install remenoscodes/git-native-issue/git-native-issuecurl -sSL https://raw.githubusercontent.com/remenoscodes/git-native-issue/main/install.sh | shOr download and run:
curl -LO https://github.com/remenoscodes/git-native-issue/releases/latest/download/git-native-issue-*.tar.gz
tar xzf git-native-issue-*.tar.gz
cd git-native-issue-*
./install.sh # Installs to /usr/local
./install.sh ~/.local # Installs to ~/.localgit clone https://github.com/remenoscodes/git-native-issue.git
cd git-native-issue
make install # System-wide (/usr/local)
make install prefix=~ # User install (~/bin)git issue version
# git-issue version 1.3.3| Command | Description |
|---|---|
git issue create <title> |
Create a new issue |
git issue ls |
List issues |
git issue show <id> |
Show issue details and comments |
git issue comment <id> |
Add a comment |
git issue edit <id> |
Edit metadata (labels, assignee, priority, milestone) |
git issue state <id> |
Change issue state |
git issue import |
Import issues from a platform (GitHub, GitLab, Gitea, Forgejo) |
git issue export |
Export issues to a platform |
git issue sync |
Two-way sync (import + export) |
git issue search <pattern> |
Search issues by text |
git issue merge <remote> |
Merge issues from a remote |
git issue fsck |
Validate issue data integrity |
git issue init [<remote>] |
Configure repo for issue tracking |
git issue create "Fix login crash" \
-m "TypeError when clicking submit" \
-l bug -l auth \
-a alice@example.com \
-p critical \
--milestone v1.0# Replace all labels
git issue edit a7f3b2c -l bug -l urgent
# Add/remove individual labels
git issue edit a7f3b2c --add-label security
git issue edit a7f3b2c --remove-label urgent
# Change assignee and priority
git issue edit a7f3b2c -a bob@example.com -p high
# Change title
git issue edit a7f3b2c -t "Fix login crash on special characters"git issue ls # Open issues (default)
git issue ls --all # All issues
git issue ls --state closed # Closed issues
git issue ls -l bug # Filter by label
git issue ls --assignee alice@example.com
git issue ls --priority critical
git issue ls --sort priority # Sort by priority (desc)
git issue ls --sort updated --reverse # Oldest updates first
git issue ls --format full # Show labels, assignee, priority, milestone
git issue ls --format oneline # Scripting-friendly (no brackets)Sort fields: created (default), updated, priority, state.
git issue search "crash" # Search titles, bodies, and comments
git issue search -i "firefox" # Case-insensitive
git issue search "bug" --state open # Only open issuesImport and export issues from/to GitHub, GitLab, Gitea, and Forgejo. Use Git as the source of truth while maintaining compatibility with hosted platforms.
Requires gh and jq.
# Import all open issues from a GitHub repo
git issue import github:owner/repo
# Import all issues (open + closed)
git issue import github:owner/repo --state all
# Preview what would be imported
git issue import github:owner/repo --dry-run
# Export local issues to GitHub
git issue export github:owner/repo
# Two-way sync (import then export)
git issue sync github:owner/repo --state allPrerequisites:
brew install gh jq # macOS
gh auth login # authenticate with GitHubSupports both GitLab.com and self-hosted instances. Requires glab (GitLab CLI) and jq.
# Import all open issues from a GitLab project
git issue import gitlab:group/project
# Import from self-hosted GitLab
git issue import gitlab:company/product \
--url https://gitlab.company.com \
--state all
# Preview what would be imported
git issue import gitlab:group/project --dry-run
# Export to GitLab
git issue export gitlab:group/project
# Two-way sync
git issue sync gitlab:group/project --state allAuthentication:
Create a GitLab Personal Access Token (PAT) with read_api (import) or api (import + export) scope:
# Via environment variable
export GITLAB_TOKEN="glpat-xxxxxxxxxxxxxxxxxxxx"
# Or via config file (recommended)
mkdir -p ~/.config/git-native-issue
echo "glpat-xxxxxxxxxxxxxxxxxxxx" > ~/.config/git-native-issue/gitlab-token
chmod 600 ~/.config/git-native-issue/gitlab-tokenHow bridges work:
importfetches issues via API, creates localrefs/issues/commits with full metadata (labels, assignee, comments, author)exportcreates platform issues from local issues, syncs comments and state- A
Provider-IDtrailer tracks the mapping (e.g.,Provider-ID: github:owner/repo#42orProvider-ID: gitlab:group/project#42) to prevent duplicates on re-import/re-export - Re-importing skips already-imported issues and appends only new comments
See also: docs/gitlab-bridge.md for detailed GitLab documentation, including migration workflows and troubleshooting.
Supports Gitea and Forgejo (Gitea soft fork), including self-hosted instances. Uses Personal Access Tokens for authentication.
# Import all open issues from a Gitea repository
git issue import gitea:owner/repo
# Import from Forgejo (e.g., Codeberg.org)
git issue import forgejo:owner/repo --url https://codeberg.org
# Import from self-hosted Gitea
git issue import gitea:company/product \
--url https://gitea.company.com \
--state all
# Preview what would be imported
git issue import gitea:owner/repo --dry-run
# Export to Gitea/Forgejo
git issue export gitea:owner/repo --url https://gitea.company.com
# Two-way sync
git issue sync gitea:owner/repo --state allAuthentication:
Create a Personal Access Token with read:issue, read:repository (import) or write:issue (export) scopes:
# Via environment variable
export GITEA_TOKEN="your-token-here"
export FORGEJO_TOKEN="your-forgejo-token" # For Forgejo instances
# Or via config file (recommended)
mkdir -p ~/.config/git-native-issue
echo "your-token-here" > ~/.config/git-native-issue/gitea-token
chmod 600 ~/.config/git-native-issue/gitea-token
# For Forgejo
echo "your-forgejo-token" > ~/.config/git-native-issue/forgejo-token
chmod 600 ~/.config/git-native-issue/forgejo-tokenHow it works:
- Uses Gitea/Forgejo REST API v1 (
/api/v1/*) - No CLI tool required (unlike GitHub/GitLab bridges)
- Requires
jqfor JSON processing - Supports both Gitea (try.gitea.io) and Forgejo (codeberg.org)
- API-compatible: Forgejo maintains Gitea API compatibility
See also: docs/gitea-bridge.md for detailed Gitea/Forgejo documentation, including self-hosted setup and troubleshooting.
git-native-issue is designed for AI coding agents. Unlike TODO comments, issues have structured metadata that agents can parse and update.
Example: Code review agent
# Agent creates issues for findings
git issue create "SQL injection risk in search" -l security -p critical
git issue create "Missing error handling in payments" -l bug -p high
# Human reviews and triages
git issue ls --priority critical
git issue state abc --close -m "False positive"Why better than TODO comments:
- ✅ Structured metadata (priority, labels, assignee)
- ✅ Full history (
git log refs/issues/xyz) - ✅ No API rate limits (all local)
- ✅ Status tracking (open/closed/in-progress)
- ✅ Searchable (
git issue search "race condition")
Agents that work well with git-issue:
- Claude Code (via CLAUDE.md integration)
- Cursor (via terminal)
- GitHub Copilot Workspace (via git integration)
- Custom agents (via git plumbing commands)
When multiple people track the same issues, their ref chains can diverge.
git issue merge reconciles them:
# Fetch and merge issues from a remote
git issue merge origin
# Detect divergences without merging
git issue merge origin --check
# Skip fetch, use existing remote tracking refs
git issue merge origin --no-fetchMerge strategy:
- New issues from remote are created locally
- If local is behind, fast-forward
- If diverged, create a merge commit with resolved metadata:
- Scalar fields (state, assignee, priority, milestone): last-writer-wins by timestamp
- Labels: three-way set merge (additions from both sides preserved, removals honored)
- Comments: union (both sides' commits reachable via merge parents)
# Validate all issue refs
git issue fsck
# Quiet mode (only errors)
git issue fsck --quietChecks: UUID format, empty tree usage, required trailers (State, Format-Version), single root commit per issue.
Each issue is a chain of commits on its own ref. It's just Git:
refs/issues/a7f3b2c1-4e5d-4f8a-b9c3-1234567890ab
|
v
[Close issue] State: closed
| Fixed-By: abc123
v
[Reproduced on Firefox] (comment)
|
v
[Fix login crash...] State: open
Labels: bug, auth
Priority: critical
Format-Version: 1
Why this works beautifully:
-
Commits are events -- Each commit is an immutable event (issue creation, comment, state change). Git's content-addressable storage gives us cryptographic integrity for free.
-
Refs are identities --
refs/issues/<uuid>points to the latest state of an issue. Git's ref machinery handles updates atomically. -
Trailers are metadata --
State: open,Labels: bug, authare standard Git trailers. They're parseable bygit interpret-trailersand queryable viagit for-each-refwith zero subprocess spawning:git for-each-ref \ --format='%(refname:short) %(contents:subject) %(trailers:key=State,valueonly)' \ refs/issues/ -
Merge commits resolve conflicts -- When two people modify the same issue offline, Git's three-way merge machinery creates a merge commit with resolved metadata. No CRDTs, no operational transforms, just merge commits.
-
Fetch/push is synchronization --
git fetch origin 'refs/issues/*'pulls issues.git push origin 'refs/issues/*'shares them. The same protocol that syncs code syncs issues.
Performance: This scales to 10,000+ issues because git for-each-ref
is a single batch operation -- not one subprocess per issue like most
Git porcelain commands.
Here's how everything fits together in Git's object model:
Repository:
.git/
refs/
heads/main → [code commits]
issues/
a7f3b2c1-... → commit(close) State: closed
↓ Fixed-By: abc123
commit(comment) "Reproduced on Firefox"
↓
commit(create) State: open
↓ Labels: bug, auth
tree(empty) (root of issue chain)
What Git provides:
• Atomic ref updates → No race conditions on concurrent edits
• Three-way merge → Automatic conflict resolution on divergence
• Content addressing → Deduplication + cryptographic integrity
• Transfer protocol → Efficient sync over SSH/HTTPS
• Garbage collection → Unreachable issues cleaned automatically
It's not "abusing Git" -- it's using Git exactly as designed: a distributed append-only content-addressable database with built-in merge resolution.
The real deliverable is ISSUE-FORMAT.md -- a standalone specification for storing issues in Git, independent of this tool. Any implementation that produces conforming refs and commits is a valid implementation.
If the Git community blesses this format, platforms like GitHub, GitLab,
and Forgejo can adopt native support for refs/issues/*, making issue
portability as natural as code portability.
Every design choice aligns with Git's philosophy: simple primitives, composed well.
Sequential IDs (issue #1, #2, #3) require coordination. In distributed systems, two people can't both create "issue #42" offline. UUIDs are collision-free by design -- the same reason Git uses SHA-1 hashes instead of sequential commit numbers.
JSON in commit messages breaks git log readability. YAML is complex
to parse. Git trailers are a 20-year-old standard (git interpret-trailers)
that's human-readable, machine-parseable, and compatible with existing
Git tooling.
The issue title is the commit subject line. This means git log refs/issues/*
naturally shows issue titles, and %(contents:subject) in git for-each-ref
extracts it with zero parsing. Git's existing formatting machinery works
out of the box.
Labels are a set. When two people modify labels offline, the merge should preserve additions from both sides and honor explicit removals. Git's three-way merge (base, ours, theirs) handles this perfectly -- no CRDTs, no vector clocks, just merge-base computation.
State (open/closed), assignee, and priority are scalar values. When two people change them offline, there's no "correct" merge -- just pick the most recent by timestamp. Simple, deterministic, and matches user expectations.
GitHub and GitLab won't adopt refs/issues/* overnight. Bridges allow
migration and interop via git issue sync without requiring webhooks,
conflict resolution UI, or operational complexity. Real-time sync is a
v2 problem.
Issues live in refs/, not the working tree. This means:
- No
.issues/directory clutteringgit status - No merge conflicts in issue files during code merges
- No "commit your issues" workflow confusion
- Issues work in bare repositories (on servers)
Distributed issue tracking has been attempted for nearly 20 years. Every previous attempt failed to gain traction. Why?
Six fundamental problems:
-
Merge conflicts -- Storing issues as files in the working tree (Bugs Everywhere, Ditz) creates merge conflicts that break
git merge. Users must resolve issue file conflicts manually, which is unacceptable. -
Network effects -- Platforms like GitHub provide issue tracking as part of a hosting service. Switching to distributed issues means losing web UI, notifications, and integrations. No single project can overcome this chicken-and-egg problem.
-
No format spec -- Every tool invented its own format. No interop, no ecosystem, no way for Git platforms to adopt it. Just code that happened to produce some files or refs.
-
Excluding non-developers -- Git is for developers. Issue tracking is for everyone. File-based storage excludes users who can't read commit logs or run shell commands.
-
Weak offline argument -- Most developers have internet. The "work offline" pitch isn't compelling enough to overcome the switching cost.
-
Resource constraints -- These were side projects, not funded products. They couldn't compete with GitHub's issue tracker on polish and features.
How git-issue addresses these:
| Problem | Solution |
|---|---|
| Merge conflicts | Issues live in refs/, not working tree. Code merges never touch issues. |
| Network effects | Ship a standalone format spec (ISSUE-FORMAT.md). Platforms can adopt it incrementally. |
| No format spec | The spec is the deliverable. Implementations are interchangeable. |
| Excluding non-developers | Start with developers. Import/export bridges keep issues in GitHub for non-dev stakeholders. |
| Weak offline argument | The real pitch: issue portability. Code outlives hosting platforms. Issues should too. |
| Resource constraints | Keep scope minimal. Format spec + one reference implementation. Ecosystem adoption is the goal, not feature parity with Jira. |
This project builds on lessons from 10+ previous attempts:
| Tool | Year | Status | Key Lesson |
|---|---|---|---|
| Fossil | 2006 | Active | Proves CRDT-based append-only model works |
| Bugs Everywhere | 2005 | Dead | File-based storage creates merge conflicts |
| ticgit | 2008 | Dead | Creator (Scott Chacon) built GitHub instead |
| git-appraise | 2015 | Dead | refs/notes/ model is elegant but needs ecosystem support |
| git-issue (Spinellis) | 2016 | Active | Works for personal use; no format spec limits ecosystem adoption |
| git-dit | 2016 | Dead | Commits + trailers works (validated our approach) |
| git-bug | 2018 | Active | CRDTs are overkill; missing format spec |
What's different this time: The format spec. No previous tool
produced a standalone, implementable specification. Every tool's
"format" was just whatever their code produced. ISSUE-FORMAT.md is
the deliverable that makes ecosystem adoption possible.
make test282 tests: core (77), bridge (37), merge/fsck (21), QoL (22), validation (36), quality (59), edge cases (13), comment sync (8), concurrency (9).
Each issue is one ref. For repositories with many issues (1000+), configure Git protocol v2 to avoid advertising all refs on every fetch:
git config protocol.version 2Protocol v2 uses server-side filtering, so only requested refs are
transferred. Without it, every git fetch advertises all refs
including refs/issues/*.
GPL-2.0 -- same as Git itself.
