Use go-git for native git operations

Replace exec-based git calls with go-git/v6 library for ~5.6x
speedup over shell version. Only pgrep remains as external call.
This commit is contained in:
2025-12-18 05:11:34 +01:00
parent 135bbf68f1
commit f1ca1ccaa0
4 changed files with 383 additions and 43 deletions

22
go.mod
View File

@ -2,4 +2,24 @@ module github.com/kjanat/claude-statusline
go 1.24.11
require golang.org/x/sys v0.39.0
require (
github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c
golang.org/x/sys v0.39.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
)

62
go.sum
View File

@ -1,2 +1,64 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d h1:nfZPVEha54DwXl8twSNxi9J8edIiqfpSvnq/mGPfgc4=
github.com/go-git/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251205091929-ed656e84d025 h1:24Uc4y1yxMe8V30NhshaDdCaTOw97BWVhVGH/m1+udM=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251205091929-ed656e84d025/go.mod h1:T6lRF5ejdxaYZLVaCTuTG1+ZSvwI/c2oeiTgBWORJ8Q=
github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c h1:pR4UmnVFMjNw956fgu+JlSAvmx37qW4ttVF0cu7DL/Q=
github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c/go.mod h1:EPzgAjDnw+TaCt1w/JUmj+SXwWHUae3c078ixiZQ10Y=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

77
main.go
View File

@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"github.com/go-git/go-git/v6"
"golang.org/x/sys/unix"
)
@ -94,10 +95,7 @@ func main() {
termWidth := getTerminalWidth() - statuslineWidthOffset
// Calculate padding
padding := termWidth - len(leftVisible) - len(rightVisible)
if padding < 1 {
padding = 1
}
padding := max(termWidth-len(leftVisible)-len(rightVisible), 1)
// Output with padding
fmt.Printf("%s%s%s", left, strings.Repeat(" ", padding), right)
@ -129,64 +127,59 @@ func getGiteaStatus() string {
}
func getGitInfo(cwd string) string {
// Change to the directory
oldDir, _ := os.Getwd()
if err := os.Chdir(cwd); err != nil {
// Open the repository (searches up from cwd)
repo, err := git.PlainOpenWithOptions(cwd, &git.PlainOpenOptions{
DetectDotGit: true,
})
if err != nil {
return ""
}
defer os.Chdir(oldDir)
// Check if we're in a git repo
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Stderr = nil
if err := cmd.Run(); err != nil {
// Get HEAD reference
head, err := repo.Head()
if err != nil {
return ""
}
// Get branch name
branch := ""
cmd = exec.Command("git", "symbolic-ref", "--short", "HEAD")
cmd.Stderr = nil
if out, err := cmd.Output(); err == nil {
branch = strings.TrimSpace(string(out))
var branch string
if head.Name().IsBranch() {
branch = head.Name().Short()
} else {
// Fallback to short SHA
cmd = exec.Command("git", "rev-parse", "--short", "HEAD")
cmd.Stderr = nil
if out, err := cmd.Output(); err == nil {
branch = strings.TrimSpace(string(out))
}
}
if branch == "" {
return ""
// Detached HEAD - use short hash
branch = head.Hash().String()[:7]
}
// Check if working tree is dirty
isDirty := false
// Check unstaged changes
cmd = exec.Command("git", "diff", "--no-ext-diff", "--quiet", "--exit-code", "--no-optional-locks")
cmd.Stderr = nil
if err := cmd.Run(); err != nil {
isDirty = true
worktree, err := repo.Worktree()
if err != nil {
return fmt.Sprintf(" git:(%s)", branch)
}
// Check staged changes
if !isDirty {
cmd = exec.Command("git", "diff-index", "--cached", "--quiet", "HEAD", "--no-optional-locks")
cmd.Stderr = nil
if err := cmd.Run(); err != nil {
isDirty = true
}
status, err := worktree.Status()
if err != nil {
return fmt.Sprintf(" git:(%s)", branch)
}
if isDirty {
if !status.IsClean() {
return fmt.Sprintf(" git:(%s) ✗", branch)
}
return fmt.Sprintf(" git:(%s)", branch)
}
func findGitRoot(path string) string {
for {
if _, err := os.Stat(filepath.Join(path, ".git")); err == nil {
return path
}
parent := filepath.Dir(path)
if parent == path {
return ""
}
path = parent
}
}
func getTerminalWidth() int {
ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
if err != nil {

265
statusline.md Normal file
View File

@ -0,0 +1,265 @@
# Status line configuration
> Create a custom status line for Claude Code to display contextual information
Make Claude Code your own with a custom status line that displays at the bottom
of the Claude Code interface, similar to how terminal prompts (PS1) work in
shells like Oh-my-zsh.
## Create a custom status line
You can either:
- Run `/statusline` to ask Claude Code to help you set up a custom status line.
By default, it will try to reproduce your terminal's prompt, but you can
provide additional instructions about the behavior you want to Claude Code,
such as `/statusline show the model name in orange`
- Directly add a `statusLine` command to your `.claude/settings.json`:
```json theme={null}
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
"padding": 0 // Optional: set to 0 to let status line go to edge
}
}
```
## How it Works
- The status line is updated when the conversation messages update
- Updates run at most every 300 ms
- The first line of stdout from your command becomes the status line text
- ANSI color codes are supported for styling your status line
- Claude Code passes contextual information about the current session (model,
directories, etc.) as JSON to your script via stdin
## JSON Input Structure
Your status line command receives structured data via stdin in JSON format:
```json theme={null}
{
"hook_event_name": "Status",
"session_id": "abc123...",
"transcript_path": "/path/to/transcript.json",
"cwd": "/current/working/directory",
"model": {
"id": "claude-opus-4-1",
"display_name": "Opus"
},
"workspace": {
"current_dir": "/current/working/directory",
"project_dir": "/original/project/directory"
},
"version": "1.0.80",
"output_style": {
"name": "default"
},
"cost": {
"total_cost_usd": 0.01234,
"total_duration_ms": 45000,
"total_api_duration_ms": 2300,
"total_lines_added": 156,
"total_lines_removed": 23
},
"context_window": {
"total_input_tokens": 15234,
"total_output_tokens": 4521,
"context_window_size": 200000,
"current_usage": {
"input_tokens": 8500,
"output_tokens": 1200,
"cache_creation_input_tokens": 5000,
"cache_read_input_tokens": 2000
}
}
}
```
## Example Scripts
### Simple Status Line
```bash theme={null}
#!/bin/bash
# Read JSON input from stdin
input=$(cat)
# Extract values using jq
MODEL_DISPLAY=$(echo "$input" | jq -r '.model.display_name')
CURRENT_DIR=$(echo "$input" | jq -r '.workspace.current_dir')
echo "[$MODEL_DISPLAY] 📁 ${CURRENT_DIR##*/}"
```
### Git-Aware Status Line
```bash theme={null}
#!/bin/bash
# Read JSON input from stdin
input=$(cat)
# Extract values using jq
MODEL_DISPLAY=$(echo "$input" | jq -r '.model.display_name')
CURRENT_DIR=$(echo "$input" | jq -r '.workspace.current_dir')
# Show git branch if in a git repo
GIT_BRANCH=""
if git rev-parse --git-dir > /dev/null 2>&1; then
BRANCH=$(git branch --show-current 2>/dev/null)
if [ -n "$BRANCH" ]; then
GIT_BRANCH=" | 🌿 $BRANCH"
fi
fi
echo "[$MODEL_DISPLAY] 📁 ${CURRENT_DIR##*/}$GIT_BRANCH"
```
### Python Example
```python theme={null}
#!/usr/bin/env python3
import json
import sys
import os
# Read JSON from stdin
data = json.load(sys.stdin)
# Extract values
model = data['model']['display_name']
current_dir = os.path.basename(data['workspace']['current_dir'])
# Check for git branch
git_branch = ""
if os.path.exists('.git'):
try:
with open('.git/HEAD', 'r') as f:
ref = f.read().strip()
if ref.startswith('ref: refs/heads/'):
git_branch = f" | 🌿 {ref.replace('ref: refs/heads/', '')}"
except:
pass
print(f"[{model}] 📁 {current_dir}{git_branch}")
```
### Node.js Example
```javascript theme={null}
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
// Read JSON from stdin
let input = "";
process.stdin.on("data", (chunk) => input += chunk);
process.stdin.on("end", () => {
const data = JSON.parse(input);
// Extract values
const model = data.model.display_name;
const currentDir = path.basename(data.workspace.current_dir);
// Check for git branch
let gitBranch = "";
try {
const headContent = fs.readFileSync(".git/HEAD", "utf8").trim();
if (headContent.startsWith("ref: refs/heads/")) {
gitBranch = ` | 🌿 ${headContent.replace("ref: refs/heads/", "")}`;
}
} catch (e) {
// Not a git repo or can't read HEAD
}
console.log(`[${model}] 📁 ${currentDir}${gitBranch}`);
});
```
### Helper Function Approach
For more complex bash scripts, you can create helper functions:
```bash theme={null}
#!/bin/bash
# Read JSON input once
input=$(cat)
# Helper functions for common extractions
get_model_name() { echo "$input" | jq -r '.model.display_name'; }
get_current_dir() { echo "$input" | jq -r '.workspace.current_dir'; }
get_project_dir() { echo "$input" | jq -r '.workspace.project_dir'; }
get_version() { echo "$input" | jq -r '.version'; }
get_cost() { echo "$input" | jq -r '.cost.total_cost_usd'; }
get_duration() { echo "$input" | jq -r '.cost.total_duration_ms'; }
get_lines_added() { echo "$input" | jq -r '.cost.total_lines_added'; }
get_lines_removed() { echo "$input" | jq -r '.cost.total_lines_removed'; }
get_input_tokens() { echo "$input" | jq -r '.context_window.total_input_tokens'; }
get_output_tokens() { echo "$input" | jq -r '.context_window.total_output_tokens'; }
get_context_window_size() { echo "$input" | jq -r '.context_window.context_window_size'; }
# Use the helpers
MODEL=$(get_model_name)
DIR=$(get_current_dir)
echo "[$MODEL] 📁 ${DIR##*/}"
```
### Context Window Usage
Display the percentage of context window consumed. The `context_window` object
contains:
- `total_input_tokens` / `total_output_tokens`: Cumulative totals across the
entire session
- `current_usage`: Current context window usage from the last API call (may be
`null` if no messages yet)
- `input_tokens`: Input tokens in current context
- `output_tokens`: Output tokens generated
- `cache_creation_input_tokens`: Tokens written to cache
- `cache_read_input_tokens`: Tokens read from cache
For accurate context percentage, use `current_usage` which reflects the actual
context window state:
```bash theme={null}
#!/bin/bash
input=$(cat)
MODEL=$(echo "$input" | jq -r '.model.display_name')
CONTEXT_SIZE=$(echo "$input" | jq -r '.context_window.context_window_size')
USAGE=$(echo "$input" | jq '.context_window.current_usage')
if [ "$USAGE" != "null" ]; then
# Calculate current context from current_usage fields
CURRENT_TOKENS=$(echo "$USAGE" | jq '.input_tokens + .cache_creation_input_tokens + .cache_read_input_tokens')
PERCENT_USED=$((CURRENT_TOKENS * 100 / CONTEXT_SIZE))
echo "[$MODEL] Context: ${PERCENT_USED}%"
else
echo "[$MODEL] Context: 0%"
fi
```
## Tips
- Keep your status line concise - it should fit on one line
- Use emojis (if your terminal supports them) and colors to make information
scannable
- Use `jq` for JSON parsing in Bash (see examples above)
- Test your script by running it manually with mock JSON input:
`echo '{"model":{"display_name":"Test"},"workspace":{"current_dir":"/test"}}' | ./statusline.sh`
- Consider caching expensive operations (like git status) if needed
## Troubleshooting
- If your status line doesn't appear, check that your script is executable
(`chmod +x`)
- Ensure your script outputs to stdout (not stderr)
---
> To find navigation and other pages in this documentation, fetch the llms.txt
> file at: https://code.claude.com/docs/llms.txt