Build a CLI tool called git-issues (binary: issues) that provides a git-native issue tracker. Issues are stored as Markdown files inside the repository itself under .issues/, making them version-controlled alongside the code they describe.
- Language: Go 1.22+
- Dependencies (minimal):
gopkg.in/yaml.v3– Frontmatter parsinggithub.com/spf13/cobra– CLI framework- No database, no network, no auth
- Binary name:
issues - Install target:
/usr/local/bin/issues
git-issues/
cmd/
root.go
new.go
list.go
show.go
edit.go
close.go
reopen.go
set.go
relate.go
unrelate.go
labels.go
graph.go
internal/
issue/
issue.go ← Issue struct + Frontmatter schema
store.go ← Read/write .issues/ directory
relations.go ← Bidirectional relation sync
id.go ← ID generation
slug.go ← Title → filename slug
git/
stage.go ← git add wrapper
config/
config.go ← .issues/.config.yml
main.go
go.mod
go.sum
README.md
.issues/
.agent.md ← generated on `issues init`
.config.yml ← generated on `issues init`
type Issue struct {
// Frontmatter
ID int `yaml:"id"`
Title string `yaml:"title"`
Status string `yaml:"status"` // open | in-progress | closed | wontfix
Priority string `yaml:"priority"` // low | medium | high | critical
Labels []string `yaml:"labels"`
Relations Relations `yaml:"relations,omitempty"`
Created string `yaml:"created"` // ISO date YYYY-MM-DD
Updated string `yaml:"updated"`
Closed string `yaml:"closed,omitempty"`
// Body (everything after frontmatter delimiter)
Body string `yaml:"-"`
// Derived (not stored)
FilePath string `yaml:"-"`
}
type Relations struct {
Blocks []int `yaml:"blocks,omitempty"`
DependsOn []int `yaml:"depends-on,omitempty"`
RelatedTo []int `yaml:"related-to,omitempty"`
Duplicates []int `yaml:"duplicates,omitempty"`
}---
id: 7
title: "Login schlägt bei leeren Passwörtern fehl"
status: open
priority: high
labels: [bug, auth]
relations:
blocks: [12, 15]
depends-on: [3]
created: 2026-03-04
updated: 2026-03-04
---
Freitext Markdown body hier.
## Notes
Weitere Notizen als freie Sections.
Critical: The frontmatter delimiter is exactly --- on its own line. The file always starts with ---, contains YAML, then ---, then the body. If no body, a newline after the closing --- is sufficient.
{id:04d}-{slug}.md
Slug generation:
- Lowercase the title
- Replace umlauts: ä→ae, ö→oe, ü→ue, ß→ss
- Remove all characters except
[a-z0-9 -] - Replace spaces with
- - Truncate to 40 characters
- Strip trailing
-
Examples:
"Login schlägt bei leeren Passwörtern fehl"→0007-login-schlaegt-bei-leeren-passwoertern"Fix auth bug"→0001-fix-auth-bug
The filename never changes after creation, even if the title is edited.
Read all existing .issues/*.md files, parse their id frontmatter field, return max(ids) + 1. Start at 1 if no issues exist. IDs are never reused, never derived from filename.
// Must implement:
func LoadAll(issuesDir string) ([]*Issue, error)
func LoadByID(issuesDir string, id int) (*Issue, error)
func Save(issuesDir string, issue *Issue) error // writes file, updates `updated` field
func Delete(issuesDir string, id int) error
func NextID(issuesDir string) (int, error)
func IssuesDir() (string, error) // finds .issues/ by walking up from cwdIssuesDir() behavior: walk up from current working directory until finding .issues/ or hitting filesystem root. Error if not found. This mirrors how git finds .git/.
Save() must:
- Set
updatedto today's date - Marshal frontmatter as YAML
- Write
---\n{yaml}---\n{body}\n - If
auto_stage: truein config, callgit add {filepath}
File: .issues/.config.yml
default_priority: medium
auto_stage: true
labels:
- bug
- feature
- auth
- security
- docstype Config struct {
DefaultPriority string `yaml:"default_priority"`
AutoStage bool `yaml:"auto_stage"`
Labels []string `yaml:"labels"`
}
func Load(issuesDir string) (*Config, error)
func Default() *Config // returns sensible defaults if no config file existsDefault values: default_priority: medium, auto_stage: true.
func Stage(filepath string) error {
cmd := exec.Command("git", "add", filepath)
cmd.Dir = filepath.Dir(filepath)
return cmd.Run()
}Only called when config.AutoStage == true. Fail silently (warn, don't error) if not inside a git repo.
issues init
Creates .issues/ directory in current working directory with:
.issues/.config.yml(default config).issues/.agent.md(agent context, see Agent Docs section).issues/.gitignorecontaining nothing (empty, so the dir is tracked)
Errors if .issues/ already exists.
issues new [--title "..."] [--priority low|medium|high|critical]
[--label <label>] (repeatable)
[--blocks <id>] (repeatable)
[--depends-on <id>] (repeatable)
[--related-to <id>] (repeatable)
[--body "..."]
Behavior:
- If
--titlenot provided, open$EDITORwith template (see Editor Template below) - Assign next ID
- Generate filename from title
- Set
status: open,priorityfrom flag or config default,created/updatedto today - Write file
- For each relation flag, call the bidirectional sync (same as
issues relate) - Stage if auto_stage
Editor Template:
---
title: ""
priority: medium
labels: []
relations:
blocks: []
depends-on: []
---
<!-- Describe the issue here. Delete this line. -->After editor closes, parse the file. If title is empty, abort with error.
Output:
Created: .issues/0007-login-schlaegt-bei-leeren-passwoertern.md (#7)
issues list [--status open|in-progress|closed|wontfix|all]
[--priority low|medium|high|critical]
[--label <label>] (repeatable, OR logic)
[--blocks <id>]
[--depends-on <id>]
[--sort priority|id|updated|created] (default: priority desc, id asc)
[--format table|json|ids]
Default: --status open, --format table
Priority sort order: critical > high > medium > low
Table format:
ID PRI STATUS TITLE LABELS
0003 critical open Auth-Refactor abschließen [auth] ⬛ 7,15
0007 high open Login schlägt bei leeren Passwörtern fehl [bug, auth] ⬜ 3
0001 medium in-progress Session Timeout konfigurierbar machen [feature]
Legend shown below table if any symbols present:
⬛ = blocks open issues ⬜ = blocked by open issue
JSON format: Array of full Issue objects as JSON.
IDs format: Space-separated IDs, one line. For scripting:
3 7 1
issues show 7
Output:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Issue #7 · high · open
Login schlägt bei leeren Passwörtern fehl
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Labels: bug, auth
Created: 2026-03-04
Updated: 2026-03-04
Relations
Blocks: #12 Session Timeout konfigurierbar machen [in-progress]
#15 Password-Reset Flow [open]
Depends on: #3 Auth-Refactor abschließen [open] ← BLOCKER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Freitext Markdown body hier.
← BLOCKER is shown next to any depends-on entry where the referenced issue is still open or in-progress.
If issue not found: Error: issue #99 not found (exit code 1).
issues edit 7
Opens $EDITOR with the raw .md file. After save:
- Re-parse frontmatter
- Validate required fields (id, title, status, priority)
- Sync any relation changes bidirectionally (diff old vs new relations, add/remove accordingly)
- Update
updatedfield - Save + stage
issues close 7
issues close 7 --wontfix --reason "Kein valider Use-Case"
- If issue has open
depends-onblockers, warn:Warning: #7 has open blocker: #3 (Auth-Refactor abschließen) Close anyway? [y/N] - Set
status: closed(orwontfix), setclosed: <today> - If
--reasonprovided, append to body:## Closed Kein valider Use-Case - Save + stage
Output: Closed: #7
issues reopen 7
Sets status: open, removes closed field. Save + stage.
Output: Reopened: #7
issues set 7 priority critical
issues set 7 status in-progress
issues set 7 title "Neuer Titel"
issues set 7 label +security ← add label
issues set 7 label -bug ← remove label
Valid fields: priority, status, title, label
For label: prefix + to add, - to remove. Without prefix, replace all labels (value is comma-separated).
Validate enum values for status and priority. Error on invalid value.
Output: Updated #7: priority = critical
issues relate 7 blocks 12
issues relate 7 depends-on 3
issues relate 7 related-to 5
issues relate 7 duplicates 2
Valid relations: blocks, depends-on, related-to, duplicates
Inverse mapping (both sides written atomically):
| Relation | Written to source | Inverse written to target |
|---|---|---|
blocks |
blocks: [target] |
depends-on: [source] |
depends-on |
depends-on: [target] |
blocks: [source] |
related-to |
related-to: [target] |
related-to: [source] |
duplicates |
duplicates: [target] |
duplicates: [source] |
Deduplication: don't add if already present. Both files staged.
Error if either ID not found.
Output:
Related: #7 blocks #12
#12 depends-on #7 (auto)
issues unrelate 7 blocks 12
Removes both sides of the relation. Both files staged.
Output:
Removed: #7 blocks #12
#12 depends-on #7 (auto)
issues labels [--sort count|alpha]
Lists all labels used across all issues with frequency. Default sort: count desc.
Output:
auth 5
bug 4
feature 2
security 1
issues graph [--open-only] [--root <id>]
Renders ASCII dependency graph. --open-only hides closed issues.
--root <id> shows only the subgraph reachable from that issue.
Output:
#3 Auth-Refactor abschließen [open, critical]
└── blocks:
#7 Login empty password [open, high]
└── blocks:
#12 Session Timeout [in-progress, medium]
#15 Password-Reset [open, high]
#8 Unrelated feature [open, low]
(no relations)
Cycle detection: if a cycle is found, print warning and break at the repeated node:
Warning: cycle detected at #7, truncating
// internal/issue/relations.go
func AddRelation(store Store, sourceID int, relation string, targetID int) error {
source, _ := store.LoadByID(sourceID)
target, _ := store.LoadByID(targetID)
addToRelation(source, relation, targetID)
addToRelation(target, inverse(relation), sourceID)
store.Save(source)
store.Save(target)
return nil
}
func inverse(relation string) string {
switch relation {
case "blocks": return "depends-on"
case "depends-on": return "blocks"
default: return relation // related-to, duplicates are self-inverse
}
}
func addToRelation(issue *Issue, relation string, id int) {
// get the slice, append id if not already present, set back
}Validate on every write:
| Field | Rule |
|---|---|
id |
Integer > 0, unique |
title |
Non-empty string |
status |
One of: open, in-progress, closed, wontfix |
priority |
One of: low, medium, high, critical |
labels |
Array of strings, may be empty |
relations.* |
Arrays of valid existing IDs (warn, don't error on missing) |
created |
Valid ISO date |
On validation error: print specific message, exit code 1, do not write file.
- Issue not found →
Error: issue #X not found(exit 1) - Not in a git-issues repo →
Error: no .issues/ directory found (run 'issues init')(exit 1) - Invalid field value →
Error: invalid status "foo", must be one of: open, in-progress, closed, wontfix(exit 1) - Editor exits without saving →
Aborted.(exit 0) - git add fails →
Warning: could not stage file (not a git repo?)(continue, don't fail)
Generated by issues init. Content is fixed:
# git-issues agent context
## Schema
Each issue is a Markdown file with YAML frontmatter.
Fields:
- id: integer, unique, never reused
- title: string
- status: open | in-progress | closed | wontfix
- priority: low | medium | high | critical
- labels: string array
- relations.blocks: int array (this issue blocks these IDs)
- relations.depends-on: int array (this issue needs these IDs first)
- relations.related-to: int array (loose relation)
- relations.duplicates: int array
## Commands
issues list --format ids --status open # space-separated open IDs
issues list --format ids --priority high # filter by priority
issues show <id> # full issue with resolved relations
issues set <id> status in-progress # mark as started
issues set <id> status open # revert to open
issues close <id> # mark done
issues close <id> --wontfix # mark as won't fix
issues relate <id> blocks <id> # declare blocking relation
issues graph --open-only # dependency tree
## Recommended workflow
1. issues list --format ids --status open → get IDs
2. issues show <id> → check for open blockers in "Depends on"
3. If blockers exist, work on those first
4. issues set <id> status in-progress → claim the issue
5. Do the work
6. issues close <id> → done, auto-staged for next commitGenerate a concise README covering: install, init, basic workflow, command reference (one-liner per command). No marketing language.
Write tests for:
- Slug generation – umlauts, special chars, truncation
- ID generation – empty dir returns 1, gaps in IDs handled correctly
- Frontmatter round-trip – parse then serialize produces identical YAML
- Relation sync –
relate 7 blocks 12writes both files correctly - Relation inverse – all four relation types
- List filtering – by status, priority, label (single and combined)
- Priority sort order – critical > high > medium > low
- Close with blocker – issues with open depends-on triggers warning
- Graph cycle detection – doesn't infinite loop
Test using Go's standard testing package. No external test framework needed.
# Makefile
build:
go build -o issues ./cmd/issues
install: build
cp issues /usr/local/bin/issues
test:
go test ./...
lint:
go vet ./...Suggested order to build incrementally:
internal/issue/issue.go– struct and frontmatter parse/writeinternal/issue/store.go– LoadAll, LoadByID, Save, NextID, IssuesDirinternal/issue/slug.go– slug generationinternal/config/config.go– config load with defaultsinternal/git/stage.go– git add wrappercmd/root.go+main.go– cobra setupissues initissues new(with --title flag first, editor later)issues list(table format first, then json/ids)issues showissues close+issues reopenissues setinternal/issue/relations.go+issues relate+issues unrelateissues edit(editor integration)issues labelsissues graph- Tests
- README
Do not implement:
- Comments as separate objects (body sections are enough)
- Assignees (status is the assignee proxy)
- Milestones (use labels)
- Any network calls
- GitHub sync
- Color output (keep it simple, add later)
- Fuzzy search
- Interactive TUI