diff --git a/internal/analyze/handler.go b/internal/analyze/handler.go index 1a9badf..30e5366 100644 --- a/internal/analyze/handler.go +++ b/internal/analyze/handler.go @@ -29,9 +29,24 @@ func Run(ctx context.Context, cfg *config.Config, dir string, opts Options) erro } // GetGraph returns the display graph for dir, running analysis if the cache -// is cold or force is true. It returns the graph and the zip hash used as the -// cache key (useful for downstream commands). +// is cold or force is true. It returns the graph and the cache key. +// +// Uses git-based fingerprinting (~1ms for clean repos) to check the cache +// before creating a zip. Only creates and uploads the zip on cache miss. 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 + } + } + } + + // Cache miss: create zip and upload. spin := ui.Start("Creating repository archive…") zipPath, err := createZip(dir) spin.Stop() @@ -45,6 +60,7 @@ func GetGraph(ctx context.Context, cfg *config.Config, dir string, force bool) ( return nil, "", err } + // Also check by zip hash (covers non-git repos and fingerprint edge cases). if !force { if g, _ := cache.Get(hash); g != nil { ui.Success("Using cached analysis (repoId: %s)", g.RepoID()) @@ -60,6 +76,14 @@ func GetGraph(ctx context.Context, cfg *config.Config, dir string, force bool) ( return nil, hash, err } + // Cache under both keys: fingerprint (fast lookup) and zip hash (fallback). + fingerprint, fpErr := cache.RepoFingerprint(dir) + if fpErr == nil { + fpKey := cache.AnalysisKey(fingerprint, "graph") + if err := cache.Put(fpKey, g); err != nil { + ui.Warn("could not write cache: %v", err) + } + } if err := cache.Put(hash, g); err != nil { ui.Warn("could not write cache: %v", err) } diff --git a/internal/cache/fingerprint.go b/internal/cache/fingerprint.go new file mode 100644 index 0000000..562b2bf --- /dev/null +++ b/internal/cache/fingerprint.go @@ -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 + } + + 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 +} + +// 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)) +} diff --git a/internal/cache/fingerprint_test.go b/internal/cache/fingerprint_test.go new file mode 100644 index 0000000..0fbf630 --- /dev/null +++ b/internal/cache/fingerprint_test.go @@ -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) + 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") + } +}