-
Notifications
You must be signed in to change notification settings - Fork 2
feat: git-based cache fingerprinting for instant cache lookups #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,55 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package cache | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "crypto/sha256" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "encoding/hex" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "os/exec" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // RepoFingerprint returns a fast, content-based cache key for the repo at dir. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // For clean git repos (~1ms): returns the commit SHA. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // For dirty git repos (~100ms): returns commitSHA:dirtyHash. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // For non-git dirs: returns empty string and an error. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func RepoFingerprint(dir string) (string, error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| commitSHA, err := gitOutput(dir, "rev-parse", "HEAD") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "", fmt.Errorf("not a git repo: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dirty, err := gitOutput(dir, "status", "--porcelain", "--untracked-files=no") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return commitSHA, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+23
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t treat At Line 24, returning ✅ Minimal correctness fix dirty, err := gitOutput(dir, "status", "--porcelain", "--untracked-files=all")
if err != nil {
- return commitSHA, nil
+ return "", fmt.Errorf("git status failed: %w", err)
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if dirty == "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return commitSHA, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Dirty: hash the diff to capture uncommitted changes. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| diff, err := gitOutput(dir, "diff", "HEAD") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return commitSHA + ":dirty", nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| h := sha256.Sum256([]byte(diff)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return commitSHA + ":" + hex.EncodeToString(h[:8]), nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+22
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
tmp="$(mktemp -d)"
cd "$tmp"
git init -q
git config user.email "test@test.com"
git config user.name "test"
echo 'package main' > main.go
git add main.go
git commit -q -m "init"
echo '--- baseline ---'
git status --porcelain --untracked-files=no | sed -n '1,5p'
git diff HEAD | wc -c
echo 'package main // new file' > new.go
echo '--- after adding untracked file ---'
echo '[status with --untracked-files=no]'
git status --porcelain --untracked-files=no | sed -n '1,5p'
echo '[status with --untracked-files=all]'
git status --porcelain --untracked-files=all | sed -n '1,5p'
echo '[git diff HEAD bytes]'
git diff HEAD | wc -cRepository: supermodeltools/cli Length of output: 301 Untracked files bypass cache invalidation and cause stale results to be returned. At lines 22 and 31–37, the code uses Concrete scenario: you commit code, run analysis (cached as commit SHA), then add a new untracked file, run analysis again—and get the stale cached result instead of re-analyzing. The fix is straightforward: switch to Suggested fix- dirty, err := gitOutput(dir, "status", "--porcelain", "--untracked-files=no")
+ dirty, err := gitOutput(dir, "status", "--porcelain", "--untracked-files=all")
if err != nil {
return commitSHA, nil
}
if dirty == "" {
return commitSHA, nil
}
+
+ // Conservative: if untracked files exist, skip fingerprint caching to avoid stale hits.
+ if strings.Contains(dirty, "?? ") {
+ return "", fmt.Errorf("untracked files present")
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // gitOutput runs a git command in dir and returns its trimmed stdout. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func gitOutput(dir string, args ...string) (string, error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| out, err := cmd.Output() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "", err | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return strings.TrimSpace(string(out)), nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // AnalysisKey builds a cache key for a specific analysis type on a repo state. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func AnalysisKey(fingerprint, analysisType string) string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| h := sha256.New() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fmt.Fprintf(h, "%s\x00%s", fingerprint, analysisType) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return hex.EncodeToString(h.Sum(nil)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| package cache | ||
|
|
||
| import ( | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "testing" | ||
| ) | ||
|
|
||
| func initGitRepo(t *testing.T) string { | ||
| t.Helper() | ||
| dir := t.TempDir() | ||
| run(t, dir, "git", "init") | ||
| run(t, dir, "git", "config", "user.email", "test@test.com") | ||
| run(t, dir, "git", "config", "user.name", "test") | ||
| if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| run(t, dir, "git", "add", ".") | ||
| run(t, dir, "git", "commit", "-m", "init") | ||
| return dir | ||
| } | ||
|
|
||
| func run(t *testing.T, dir, name string, args ...string) { | ||
| t.Helper() | ||
| cmd := exec.Command(name, args...) | ||
| cmd.Dir = dir | ||
| cmd.Stdout = os.Stdout | ||
| cmd.Stderr = os.Stderr | ||
| if err := cmd.Run(); err != nil { | ||
| t.Fatalf("%s %v: %v", name, args, err) | ||
| } | ||
| } | ||
|
|
||
| func TestRepoFingerprint_CleanRepo(t *testing.T) { | ||
| dir := initGitRepo(t) | ||
| fp, err := RepoFingerprint(dir) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if fp == "" { | ||
| t.Fatal("expected non-empty fingerprint") | ||
| } | ||
| // Should be a plain commit SHA (40 hex chars). | ||
| if len(fp) != 40 { | ||
| t.Errorf("expected 40-char commit SHA, got %q (%d chars)", fp, len(fp)) | ||
| } | ||
| } | ||
|
|
||
| func TestRepoFingerprint_DirtyRepo(t *testing.T) { | ||
| dir := initGitRepo(t) | ||
| // Modify a tracked file. | ||
| if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n// dirty\n"), 0o600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| fp, err := RepoFingerprint(dir) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| // Dirty fingerprint should contain a colon separator. | ||
| if len(fp) <= 40 { | ||
| t.Errorf("expected dirty fingerprint (>40 chars), got %q", fp) | ||
| } | ||
| } | ||
|
|
||
| func TestRepoFingerprint_StableForClean(t *testing.T) { | ||
| dir := initGitRepo(t) | ||
| fp1, _ := RepoFingerprint(dir) | ||
| fp2, _ := RepoFingerprint(dir) | ||
|
Comment on lines
+68
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These tests should fail fast on fingerprint errors. At Line 68, Line 69, Line 77, and Line 85, ignored errors can mask regressions (e.g., empty fingerprints compared as equal). Please assert 🔧 Suggested test hardening- fp1, _ := RepoFingerprint(dir)
- fp2, _ := RepoFingerprint(dir)
+ fp1, err := RepoFingerprint(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fp2, err := RepoFingerprint(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
if fp1 != fp2 {
t.Errorf("fingerprint should be stable: %q != %q", fp1, fp2)
}Also applies to: 77-77, 85-85 🤖 Prompt for AI Agents |
||
| if fp1 != fp2 { | ||
| t.Errorf("fingerprint should be stable: %q != %q", fp1, fp2) | ||
| } | ||
| } | ||
|
|
||
| func TestRepoFingerprint_ChangesAfterCommit(t *testing.T) { | ||
| dir := initGitRepo(t) | ||
| fp1, _ := RepoFingerprint(dir) | ||
|
|
||
| if err := os.WriteFile(filepath.Join(dir, "new.go"), []byte("package main\n"), 0o600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| run(t, dir, "git", "add", ".") | ||
| run(t, dir, "git", "commit", "-m", "second") | ||
|
|
||
| fp2, _ := RepoFingerprint(dir) | ||
| if fp1 == fp2 { | ||
| t.Error("fingerprint should change after commit") | ||
| } | ||
| } | ||
|
|
||
| func TestRepoFingerprint_NotGitRepo(t *testing.T) { | ||
| dir := t.TempDir() | ||
| _, err := RepoFingerprint(dir) | ||
| if err == nil { | ||
| t.Error("expected error for non-git dir") | ||
| } | ||
| } | ||
|
|
||
| func TestAnalysisKey_DifferentTypes(t *testing.T) { | ||
| fp := "abc123" | ||
| k1 := AnalysisKey(fp, "graph") | ||
| k2 := AnalysisKey(fp, "dead-code") | ||
| if k1 == k2 { | ||
| t.Error("different analysis types should produce different keys") | ||
| } | ||
| } | ||
|
|
||
| func TestAnalysisKey_Stable(t *testing.T) { | ||
| k1 := AnalysisKey("abc", "graph") | ||
| k2 := AnalysisKey("abc", "graph") | ||
| if k1 != k2 { | ||
| t.Error("same inputs should produce same key") | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Return one canonical key for the same repo state.
Simple repro: on a cold cache, the first call returns
hashat Line 92; the second call on the unchanged repo returnsfpKeyfrom Lines 41-44. That breaks the currentGetGraphcontract ininternal/analyze/integration_test.go:64-65, which expects both calls to yield the same key. It also means a legacy zip-hash hit at Lines 65-67 never gets upgraded to the fast path because the fingerprint entry is never backfilled.💡 Suggested shape
func GetGraph(ctx context.Context, cfg *config.Config, dir string, force bool) (*api.Graph, string, error) { - // Fast path: check cache using git fingerprint before creating zip. - if !force { - fingerprint, err := cache.RepoFingerprint(dir) - if err == nil { - key := cache.AnalysisKey(fingerprint, "graph") - if g, _ := cache.Get(key); g != nil { - ui.Success("Using cached analysis (repoId: %s)", g.RepoID()) - return g, key, nil - } - } - } + var fpKey string + if fingerprint, err := cache.RepoFingerprint(dir); err == nil { + fpKey = cache.AnalysisKey(fingerprint, "graph") + if !force { + if g, _ := cache.Get(fpKey); g != nil { + ui.Success("Using cached analysis (repoId: %s)", g.RepoID()) + return g, fpKey, nil + } + } + } @@ if !force { if g, _ := cache.Get(hash); g != nil { + if fpKey != "" { + if err := cache.Put(fpKey, g); err != nil { + ui.Warn("could not write cache: %v", err) + } + ui.Success("Using cached analysis (repoId: %s)", g.RepoID()) + return g, fpKey, nil + } ui.Success("Using cached analysis (repoId: %s)", g.RepoID()) return g, hash, nil } } @@ - return g, hash, nil + if fpKey != "" { + return g, fpKey, nil + } + return g, hash, nil }Also applies to: 63-68, 91-92
🤖 Prompt for AI Agents