gitmeta

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 6, 2026 License: MIT Imports: 9 Imported by: 0

README

gitmeta

Go Reference CI Go Report Card

Fast per-file git metadata for Go — last-commit time / author / subject, first-seen, commit count (churn), and tracked / ignored status — resolved by scanning a working tree once and answering per-path lookups in constant time. Zero dependencies (shells out to the system git binary).

The batch design is the point: one Cache runs git ls-files + a single git log pass up front, so a 10k-file / 5k-commit repo costs one git invocation (~½ s) instead of 10k git log -1 -- <path> calls (~100 s).

go get github.com/richardwooding/gitmeta

One-shot Cache

cache, err := gitmeta.New(ctx, "/path/to/repo")
if err != nil { /* git error */ }
if cache == nil {
    // not a git working tree (or no git binary) — treat as "no git data"
}

if info, ok := cache.Lookup("/path/to/repo/main.go"); ok {
    fmt.Println(info.LastCommitTime, info.LastCommitAuthor, info.CommitCount)
}

cache.IsTracked(path) // bool
cache.IsIgnored(path) // bool

Lookup returns a FileGitInfo:

type FileGitInfo struct {
    LastCommitTime    time.Time
    LastCommitAuthor  string
    LastCommitSubject string
    FirstSeen         time.Time
    CommitCount       int // churn proxy
}

Why git rather than filesystem mtimes? A fresh clone sets every file's mtime to checkout time — so "recently changed" / "hot file" questions need git history, not the filesystem.

Pool — reuse across calls

A Pool keeps one Cache per repo and re-validates on HEAD change, so repeated lookups over an unchanging tree don't re-scan. Ideal for a long-running process (server, watcher, language tooling) that answers many git-metadata queries.

pool := gitmeta.NewPool()
cache, err := pool.Get(ctx, root) // built once per repo, refreshed when HEAD moves

Requirements

  • Go 1.21+, zero third-party dependencies.
  • The system git binary on PATH (gitmeta.HasGitBinary() reports its presence; New returns a nil Cache when git is absent or the path isn't a working tree).

License

MIT — see LICENSE.


Extracted from file-search-on, where it powers the git_* search attributes and the hot_files / recent_commits presets.

Documentation

Overview

Package gitmeta resolves per-file git metadata (last-commit time + author + subject, first-seen, churn, tracked/ignored status) for a single working tree by shelling out to the system `git` binary once per walk.

One Cache scans the entire repository up front (`git ls-files`, `git ls-files --others --ignored`, and a single `git log` pass keyed by HEAD), then answers per-path Lookup / IsTracked / IsIgnored in constant time. The batch architecture is dramatically cheaper than per-file `git log -1 -- <path>` on any non-trivial repo — a 10k-file tree with 5k commits costs one git invocation (~500ms), not 10k (~100s).

New returns a nil Cache (and nil error) when the supplied root isn't inside a git working tree, or when the `git` binary isn't on PATH — callers MUST handle nil and treat it as the "no git data; leave fields at their zero values" signal rather than a hard failure. A Pool (pool.go) caches one Cache per repo across calls and re-validates on HEAD change, so repeated lookups over an unchanging tree are free.

Requires the system `git` binary (HasGitBinary reports its presence).

Example

Example shows the one-shot Cache: scan a working tree once, then answer per-file metadata lookups in constant time. (Output is omitted because commit times/authors are repo-specific.)

package main

import (
	"context"
	"fmt"

	"github.com/richardwooding/gitmeta"
)

func main() {
	ctx := context.Background()

	cache, err := gitmeta.New(ctx, "/path/to/repo")
	if err != nil {
		panic(err)
	}
	if cache == nil {
		fmt.Println("not a git working tree")
		return
	}

	if info, ok := cache.Lookup("/path/to/repo/main.go"); ok {
		fmt.Printf("last touched %s by %s — %d commits\n",
			info.LastCommitTime.Format("2006-01-02"),
			info.LastCommitAuthor,
			info.CommitCount)
	}
}

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func HasGitBinary

func HasGitBinary() bool

HasGitBinary returns true when the `git` executable is on PATH. Useful for CLI callers that want to warn up front rather than silently produce empty git_* attributes. Cheap (one exec.LookPath).

Types

type Cache

type Cache struct {
	// contains filtered or unexported fields
}

Cache is the per-repository scan result. Build via New; consult via Lookup / IsTracked / IsIgnored. All read methods are safe for concurrent use (Cache is effectively immutable after construction).

func New

func New(ctx context.Context, root string) (*Cache, error)

New scans the git working tree containing root and returns a Cache. Returns nil, nil when root is not inside any git working tree (silent skip — the caller treats this as "no git data available"). Returns nil, err only on hard failures (git binary missing, subprocess crash, ctx cancellation).

The scan runs three git invocations concurrently after the initial rev-parse and waits for all of them. ctx propagates to each subprocess via exec.CommandContext, so a cancelled walk tears the git processes down promptly.

func (*Cache) HeadSHA

func (c *Cache) HeadSHA() string

HeadSHA returns the repository's HEAD commit SHA at the time the Cache was built. Useful for invalidation when the caller persists Cache results across processes.

func (*Cache) IsIgnored

func (c *Cache) IsIgnored(absPath string) bool

IsIgnored returns true when absPath is matched by .gitignore (or one of the other standard ignore-rule sources git consults) but not in the index. Tracked files are never reported as ignored even if a later .gitignore rule would have excluded them — that matches git's own `check-ignore` semantics.

func (*Cache) IsTracked

func (c *Cache) IsTracked(absPath string) bool

IsTracked is the boolean-only form of Lookup: true when absPath is in git's index for this working tree.

func (*Cache) Lookup

func (c *Cache) Lookup(absPath string) (FileGitInfo, bool)

Lookup returns git metadata for absPath. ok=false means absPath is not tracked by git in this working tree (either untracked, ignored, or outside the repo entirely). The caller should leave git_* CEL fields at their zero values in that case.

func (*Cache) RepoRoot

func (c *Cache) RepoRoot() string

RepoRoot returns the repository's top-level absolute directory (the output of `git rev-parse --show-toplevel`). Exposed so callers can rebase a walk root against it.

type FileGitInfo

type FileGitInfo struct {
	LastCommitTime    time.Time
	LastCommitAuthor  string
	LastCommitSubject string
	FirstSeen         time.Time
	CommitCount       int
}

FileGitInfo carries the per-file metadata Cache resolves for every tracked path in a working tree. Zero values are meaningful: a fresh commit's CommitCount==1 and FirstSeen==LastCommitTime.

type Pool

type Pool struct {
	// contains filtered or unexported fields
}

Pool caches *Cache instances by canonical repository root, refreshed when HEAD changes. Safe for concurrent use.

Designed to live for an MCP server's process lifetime so multiple `with_git=true` search calls against the same repo share one gitmeta.New() pass. HEAD invalidation runs on every Get — one `git rev-parse HEAD` per call (~3-5ms) — so a `git commit` or `git checkout` between calls is picked up without operator action.

Bounded memory in practice: MCP servers are typically cwd-anchored, so the map holds one entry per process. No LRU cap for v1.

Example

ExamplePool shows reusing one Cache per repo across many calls; the pool re-validates on HEAD change, so an unchanged tree is scanned once.

package main

import (
	"context"

	"github.com/richardwooding/gitmeta"
)

func main() {
	pool := gitmeta.NewPool()
	ctx := context.Background()

	for _, root := range []string{"/repo/a", "/repo/b", "/repo/a"} {
		cache, err := pool.Get(ctx, root) // "/repo/a" the 2nd time is a cache hit
		if err != nil || cache == nil {
			continue
		}
		_ = cache.IsTracked(root + "/README.md")
	}
}

func NewPool

func NewPool() *Pool

NewPool returns an empty Pool ready for Get / Warm calls. Allocates only the underlying map — building actual caches happens on first Get. Cheap enough that the MCP server constructs one unconditionally.

func (*Pool) Get

func (p *Pool) Get(ctx context.Context, root string) (*Cache, error)

Get returns a Cache for the git tree containing root. If a cached entry exists for the same canonical repo root AND the current HEAD still matches, the cached Cache is returned unchanged. Otherwise (no entry, or HEAD moved since last Get) the cache is rebuilt via New and stored.

Returns (nil, nil) when root is not inside a git tree — same silent-skip contract as New. nil-receiver safe (returns nil, nil) for callers that don't always carry a pool.

Cost on a hit: one `git rev-parse HEAD` (~3-5ms) for the HEAD-sha staleness check. Cost on a miss: the full New() pass (~500ms on a 10k-file repo). The miss is paid only on the first call after process start or after a commit / checkout.

func (*Pool) Len

func (p *Pool) Len() int

Len returns the number of cached entries. Exposed for tests and future diagnostics (e.g. monitor_info "git pool: N repos").

func (*Pool) Warm

func (p *Pool) Warm(ctx context.Context, root string) error

Warm is Get-discard: pre-build the cache for root so the first search call doesn't pay the gitmeta.New() cost. Reuses Get's HEAD-validation logic, so a stale entry refreshes automatically. Returns the same nil-for-non-git-tree contract as Get; callers typically ignore the error and log the elapsed time.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL