Distributed issue tracking using Git's native data model.
Command: git issue (simple to use, despite the project name)
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
Push issues to any remote. Fetch them back. They travel with the code:
$ 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)yay -S git-native-issue # Coming soongit issue version
# git-issue version 1.0.2| 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 GitHub |
git issue export |
Export issues to GitHub |
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 (coming in v1.2.0)
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:
- GitHub Copilot Workspace (via git integration)
- Cursor (via terminal)
- 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 without solving real-time two-way sync (which
requires webhooks, conflict resolution UI, and operational complexity).
Start with batch import/export. Live 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 test153 tests: 76 core + 36 bridge + 20 merge/fsck + 21 QoL.
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.