diff --git a/cmd/deadcode.go b/cmd/deadcode.go index a591191..2dbe6ad 100644 --- a/cmd/deadcode.go +++ b/cmd/deadcode.go @@ -13,13 +13,13 @@ func init() { c := &cobra.Command{ Use: "dead-code [path]", Aliases: []string{"dc"}, - Short: "Find functions with no callers", - Long: `Analyses the call graph and reports functions that are never called -from anywhere in the repository. + Short: "Find unreachable functions using static analysis", + Long: `Uploads the repository to the Supermodel API and runs multi-phase dead +code analysis including call graph reachability, entry point detection, +and transitive propagation. -Exported functions, entry points (main, init), and test functions are -excluded by default because they are reachable by external callers. -Pass --include-exports to include them.`, +Results include confidence levels (high/medium/low), line numbers, and +explanations for why each function was flagged.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() @@ -38,7 +38,9 @@ Pass --include-exports to include them.`, } c.Flags().BoolVar(&opts.Force, "force", false, "re-analyze even if a cached result exists") - c.Flags().BoolVar(&opts.IncludeExports, "include-exports", false, "include exported functions in results") + c.Flags().StringVar(&opts.MinConfidence, "min-confidence", "", "minimum confidence: high, medium, or low") + c.Flags().IntVar(&opts.Limit, "limit", 0, "maximum number of candidates to return") + c.Flags().StringArrayVar(&opts.Ignore, "ignore", nil, "glob pattern to exclude from results (repeatable, supports **)") c.Flags().StringVarP(&opts.Output, "output", "o", "", "output format: human|json") rootCmd.AddCommand(c) diff --git a/internal/api/client.go b/internal/api/client.go index f38b544..1e462f5 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -79,6 +79,63 @@ func (c *Client) Analyze(ctx context.Context, zipPath, idempotencyKey string) (* // postZip sends the repository ZIP to the analyze endpoint and returns the // raw job response (which may be pending, processing, or completed). func (c *Client) postZip(ctx context.Context, zipPath, idempotencyKey string) (*JobResponse, error) { + return c.postZipTo(ctx, zipPath, idempotencyKey, analyzeEndpoint) +} + +// deadCodeEndpoint is the API path for dead code analysis. +const deadCodeEndpoint = "/v1/analysis/dead-code" + +// DeadCode uploads a repository ZIP and runs dead code analysis, +// polling until the async job completes and returning the result. +func (c *Client) DeadCode(ctx context.Context, zipPath, idempotencyKey string, minConfidence string, limit int) (*DeadCodeResult, error) { + endpoint := deadCodeEndpoint + sep := "?" + if minConfidence != "" { + endpoint += sep + "min_confidence=" + minConfidence + sep = "&" + } + if limit > 0 { + endpoint += sep + fmt.Sprintf("limit=%d", limit) + } + + job, err := c.postZipTo(ctx, zipPath, idempotencyKey, endpoint) + if err != nil { + return nil, err + } + + for job.Status == "pending" || job.Status == "processing" { + wait := time.Duration(job.RetryAfter) * time.Second + if wait <= 0 { + wait = 5 * time.Second + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + + job, err = c.postZipTo(ctx, zipPath, idempotencyKey, endpoint) + if err != nil { + return nil, err + } + } + + if job.Error != nil { + return nil, fmt.Errorf("dead code analysis failed: %s", *job.Error) + } + if job.Status != "completed" { + return nil, fmt.Errorf("unexpected job status: %s", job.Status) + } + + var result DeadCodeResult + if err := json.Unmarshal(job.Result, &result); err != nil { + return nil, fmt.Errorf("decode dead code result: %w", err) + } + return &result, nil +} + +// postZipTo sends a repository ZIP to the given endpoint and returns the job response. +func (c *Client) postZipTo(ctx context.Context, zipPath, idempotencyKey, endpoint string) (*JobResponse, error) { f, err := os.Open(zipPath) if err != nil { return nil, err @@ -97,7 +154,7 @@ func (c *Client) postZip(ctx context.Context, zipPath, idempotencyKey string) (* mw.Close() var job JobResponse - if err := c.request(ctx, http.MethodPost, analyzeEndpoint, mw.FormDataContentType(), &buf, idempotencyKey, &job); err != nil { + if err := c.request(ctx, http.MethodPost, endpoint, mw.FormDataContentType(), &buf, idempotencyKey, &job); err != nil { return nil, err } return &job, nil diff --git a/internal/api/types.go b/internal/api/types.go index e71b270..9ed027e 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -107,6 +107,48 @@ type jobResult struct { Graph Graph `json:"graph"` } +// DeadCodeResult is the result from /v1/analysis/dead-code. +type DeadCodeResult struct { + Metadata DeadCodeMetadata `json:"metadata"` + DeadCodeCandidates []DeadCodeCandidate `json:"deadCodeCandidates"` + AliveCode []AliveCode `json:"aliveCode"` + EntryPoints []EntryPoint `json:"entryPoints"` +} + +// DeadCodeMetadata holds summary stats for a dead code analysis. +type DeadCodeMetadata struct { + TotalDeclarations int `json:"totalDeclarations"` + DeadCodeCandidates int `json:"deadCodeCandidates"` + AliveCode int `json:"aliveCode"` + AnalysisMethod string `json:"analysisMethod"` + AnalysisStartTime string `json:"analysisStartTime"` + AnalysisEndTime string `json:"analysisEndTime"` +} + +// DeadCodeCandidate is a function flagged as unreachable. +type DeadCodeCandidate struct { + File string `json:"file"` + Name string `json:"name"` + Line int `json:"line"` + Type string `json:"type"` + Confidence string `json:"confidence"` + Reason string `json:"reason"` +} + +// AliveCode is a function confirmed as reachable. +type AliveCode struct { + File string `json:"file"` + Name string `json:"name"` + Line int `json:"line"` + Type string `json:"type"` + CallerCount int `json:"callerCount"` +} + +// EntryPoint is a detected entry point that should not be flagged as dead. +type EntryPoint struct { + File string `json:"file"` +} + // Error represents a non-2xx response from the API. type Error struct { StatusCode int `json:"-"` diff --git a/internal/config/config.go b/internal/config/config.go index ad1c017..6d2e5fb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,10 +32,15 @@ func Path() string { } // Load reads the config file. Returns defaults when the file does not exist. +// Environment variables override file values: +// - SUPERMODEL_API_KEY overrides api_key +// - SUPERMODEL_API_BASE overrides api_base func Load() (*Config, error) { data, err := os.ReadFile(Path()) if os.IsNotExist(err) { - return defaults(), nil + cfg := defaults() + cfg.applyEnv() + return cfg, nil } if err != nil { return nil, fmt.Errorf("read config: %w", err) @@ -45,6 +50,7 @@ func Load() (*Config, error) { return nil, fmt.Errorf("parse config: %w", err) } cfg.applyDefaults() + cfg.applyEnv() return &cfg, nil } @@ -84,3 +90,12 @@ func (c *Config) applyDefaults() { c.Output = defaultOutput } } + +func (c *Config) applyEnv() { + if key := os.Getenv("SUPERMODEL_API_KEY"); key != "" { + c.APIKey = key + } + if base := os.Getenv("SUPERMODEL_API_BASE"); base != "" { + c.APIBase = base + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index adaf781..c4d549d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,6 +9,8 @@ import ( func TestLoadDefaults(t *testing.T) { t.Setenv("HOME", t.TempDir()) + t.Setenv("SUPERMODEL_API_KEY", "") + t.Setenv("SUPERMODEL_API_BASE", "") cfg, err := Load() if err != nil { t.Fatal(err) @@ -23,6 +25,8 @@ func TestLoadDefaults(t *testing.T) { func TestSaveAndLoad(t *testing.T) { t.Setenv("HOME", t.TempDir()) + t.Setenv("SUPERMODEL_API_KEY", "") + t.Setenv("SUPERMODEL_API_BASE", "") cfg := &Config{APIKey: "test-key", APIBase: DefaultAPIBase, Output: "json"} if err := cfg.Save(); err != nil { t.Fatal(err) diff --git a/internal/deadcode/glob.go b/internal/deadcode/glob.go new file mode 100644 index 0000000..8259935 --- /dev/null +++ b/internal/deadcode/glob.go @@ -0,0 +1,53 @@ +package deadcode + +import ( + "path/filepath" + "strings" +) + +// matchGlob reports whether filePath matches the glob pattern. +// Supports *, ?, [...] within a single path segment (via filepath.Match) +// and ** to match zero or more path segments. +// +// Examples: +// +// matchGlob("dist/**", "dist/index.js") → true +// matchGlob("**/generated/**", "src/generated/api.go") → true +// matchGlob("**/*.test.ts", "src/foo.test.ts") → true +func matchGlob(pattern, filePath string) bool { + pattern = filepath.ToSlash(pattern) + filePath = filepath.ToSlash(filePath) + return matchSegments(strings.Split(pattern, "/"), strings.Split(filePath, "/")) +} + +func matchSegments(pat, path []string) bool { + for len(pat) > 0 { + seg := pat[0] + pat = pat[1:] + + if seg == "**" { + // ** at the end matches one or more remaining segments (mirrors minimatch behaviour). + if len(pat) == 0 { + return len(path) > 0 + } + // Try consuming 0, 1, 2, … path segments before resuming. + for i := 0; i <= len(path); i++ { + if matchSegments(pat, path[i:]) { + return true + } + } + return false + } + + if len(path) == 0 { + return false + } + + ok, err := filepath.Match(seg, path[0]) + if err != nil || !ok { + return false + } + path = path[1:] + } + return len(path) == 0 +} diff --git a/internal/deadcode/handler.go b/internal/deadcode/handler.go index 6c32d69..19594b1 100644 --- a/internal/deadcode/handler.go +++ b/internal/deadcode/handler.go @@ -5,117 +5,92 @@ import ( "fmt" "io" "os" - "sort" - "strings" - "unicode" - "github.com/supermodeltools/cli/internal/analyze" "github.com/supermodeltools/cli/internal/api" + "github.com/supermodeltools/cli/internal/cache" "github.com/supermodeltools/cli/internal/config" "github.com/supermodeltools/cli/internal/ui" ) // Options configures the dead-code command. type Options struct { - Force bool // bypass cache - Output string // "human" | "json" - IncludeExports bool // include exported (public) functions in results + Force bool // bypass cache + Output string // "human" | "json" + MinConfidence string // "high" | "medium" | "low" + Limit int // max candidates to return; 0 = all + Ignore []string // glob patterns to exclude (supports **) } -// Result is a function with no detected callers. -type Result struct { - Name string `json:"name"` - File string `json:"file"` -} - -// Run finds functions with no incoming call edges and prints them. +// Run uploads the repo and runs dead code analysis via the dedicated API endpoint. func Run(ctx context.Context, cfg *config.Config, dir string, opts Options) error { - g, _, err := analyze.GetGraph(ctx, cfg, dir, opts.Force) + spin := ui.Start("Creating repository archive…") + zipPath, err := createZip(dir) + spin.Stop() if err != nil { - return err + return fmt.Errorf("create archive: %w", err) } - results := findDeadCode(g, opts.IncludeExports) - return printResults(os.Stdout, results, ui.ParseFormat(opts.Output)) -} + defer os.Remove(zipPath) -// findDeadCode returns Function nodes that have no incoming CALLS relationships. -// Entry points (main, init, test functions, and — by default — exported -// functions) are excluded because they are reachable by definition. -func findDeadCode(g *api.Graph, includeExports bool) []Result { - // Build set of function node IDs that receive at least one CALLS edge. - called := make(map[string]bool) - for _, rel := range g.Rels() { - if rel.Type == "calls" || rel.Type == "contains_call" { - called[rel.EndNode] = true - } + hash, err := cache.HashFile(zipPath) + if err != nil { + return err } - var results []Result - for _, n := range g.NodesByLabel("Function") { - if called[n.ID] { - continue - } - name := n.Prop("name", "qualifiedName") - file := n.Prop("file", "path") - - if isEntryPoint(name, file, includeExports) { - continue - } - - results = append(results, Result{Name: name, File: file}) + client := api.New(cfg) + spin = ui.Start("Analyzing dead code…") + result, err := client.DeadCode(ctx, zipPath, "deadcode-"+hash[:16], opts.MinConfidence, opts.Limit) + spin.Stop() + if err != nil { + return err } - sort.Slice(results, func(i, j int) bool { - if results[i].File != results[j].File { - return results[i].File < results[j].File - } - return results[i].Name < results[j].Name - }) - return results -} - -// isEntryPoint reports whether a function should be excluded from dead-code results. -func isEntryPoint(name, file string, includeExports bool) bool { - // Strip any receiver prefix (e.g. "(*Server).Start" → "Start") - bare := name - if idx := strings.LastIndex(name, "."); idx >= 0 { - bare = name[idx+1:] + if len(opts.Ignore) > 0 { + result.DeadCodeCandidates = filterIgnored(result.DeadCodeCandidates, opts.Ignore) } - bare = strings.TrimPrefix(bare, "*") - if bare == "main" || bare == "init" { - return true - } - // Test functions (TestX, BenchmarkX, FuzzX, ExampleX) - for _, prefix := range []string{"Test", "Benchmark", "Fuzz", "Example"} { - if strings.HasPrefix(bare, prefix) { - return true - } - } - // Exported functions are reachable by callers outside the repo - if !includeExports && bare != "" && unicode.IsUpper(rune(bare[0])) { - return true - } - // Files ending in _test.go — everything in there is an entry point - if strings.HasSuffix(file, "_test.go") { - return true - } - return false + return printResults(os.Stdout, result, ui.ParseFormat(opts.Output)) } -func printResults(w io.Writer, results []Result, fmt_ ui.Format) error { +func printResults(w io.Writer, result *api.DeadCodeResult, fmt_ ui.Format) error { if fmt_ == ui.FormatJSON { - return ui.JSON(w, results) + return ui.JSON(w, result) } - if len(results) == 0 { + + candidates := result.DeadCodeCandidates + if len(candidates) == 0 { fmt.Fprintln(w, "No dead code detected.") return nil } - rows := make([][]string, len(results)) - for i, r := range results { - rows[i] = []string{r.Name, r.File} + + rows := make([][]string, len(candidates)) + for i, c := range candidates { + line := "" + if c.Line > 0 { + line = fmt.Sprintf("%d", c.Line) + } + rows[i] = []string{c.File, line, c.Name, c.Confidence, c.Reason} } - ui.Table(w, []string{"FUNCTION", "FILE"}, rows) - fmt.Fprintf(w, "\n%d unreachable function(s) found.\n", len(results)) + ui.Table(w, []string{"FILE", "LINE", "FUNCTION", "CONFIDENCE", "REASON"}, rows) + + fmt.Fprintf(w, "\n%d dead code candidate(s) out of %d total declarations.\n", + len(candidates), result.Metadata.TotalDeclarations) return nil } + +// filterIgnored removes candidates whose file path matches any of the given glob patterns. +func filterIgnored(candidates []api.DeadCodeCandidate, patterns []string) []api.DeadCodeCandidate { + out := candidates[:0:0] + for _, c := range candidates { + ignored := false + for _, p := range patterns { + if matchGlob(p, c.File) { + ignored = true + break + } + } + if !ignored { + out = append(out, c) + } + } + return out +} diff --git a/internal/deadcode/handler_test.go b/internal/deadcode/handler_test.go index 9528bab..39bdfcd 100644 --- a/internal/deadcode/handler_test.go +++ b/internal/deadcode/handler_test.go @@ -1,80 +1,167 @@ package deadcode import ( + "bytes" + "encoding/json" + "strings" "testing" "github.com/supermodeltools/cli/internal/api" + "github.com/supermodeltools/cli/internal/ui" ) -func TestIsEntryPoint(t *testing.T) { - tests := []struct { - name string - fnName string - file string - includeExports bool - want bool +// ---- glob matching ---- + +func TestMatchGlob(t *testing.T) { + cases := []struct { + pattern string + path string + want bool }{ - {"main is entry", "main", "main.go", false, true}, - {"init is entry", "init", "foo.go", false, true}, - {"Test is entry", "TestFoo", "foo_test.go", false, true}, - {"Benchmark is entry", "BenchmarkBar", "foo_test.go", false, true}, - {"Fuzz is entry", "FuzzBaz", "foo_test.go", false, true}, - {"Example is entry", "ExampleThing", "foo_test.go", false, true}, - {"exported excluded by default", "Handler", "server.go", false, true}, - {"exported included when flag set", "Handler", "server.go", true, false}, - {"unexported not entry", "process", "server.go", false, false}, - {"test file unexported", "helper", "util_test.go", false, true}, - {"method receiver stripped", "(*Server).Start", "server.go", false, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isEntryPoint(tt.fnName, tt.file, tt.includeExports) - if got != tt.want { - t.Errorf("isEntryPoint(%q, %q, %v) = %v, want %v", - tt.fnName, tt.file, tt.includeExports, got, tt.want) - } - }) + // exact + {"src/utils.ts", "src/utils.ts", true}, + {"src/utils.ts", "src/other.ts", false}, + // single-segment wildcard + {"src/*.ts", "src/utils.ts", true}, + {"src/*.ts", "src/nested/utils.ts", false}, + // ** at end + {"dist/**", "dist/index.js", true}, + {"dist/**", "dist/sub/index.js", true}, + {"dist/**", "src/index.js", false}, + // ** at start + {"**/generated/**", "src/generated/api.go", true}, + {"**/generated/**", "generated/api.go", true}, + {"**/generated/**", "src/other/api.go", false}, + // ** matching extension + {"**/*.test.ts", "src/foo.test.ts", true}, + {"**/*.test.ts", "src/nested/foo.test.ts", true}, + {"**/*.test.ts", "src/foo.ts", false}, + // no match on empty path tail + {"src/**", "src", false}, + {"src/**", "src/a", true}, + } + + for _, tc := range cases { + got := matchGlob(tc.pattern, tc.path) + if got != tc.want { + t.Errorf("matchGlob(%q, %q) = %v, want %v", tc.pattern, tc.path, got, tc.want) + } + } +} + +// ---- filterIgnored ---- + +func TestFilterIgnored_NoPatterns(t *testing.T) { + candidates := sampleResult().DeadCodeCandidates + got := filterIgnored(candidates, nil) + if len(got) != len(candidates) { + t.Errorf("expected %d candidates, got %d", len(candidates), len(got)) } } -func TestFindDeadCode(t *testing.T) { - g := &api.Graph{ - Nodes: []api.Node{ - node("f1", "Function", "process", "server.go"), - node("f2", "Function", "main", "main.go"), - node("f3", "Function", "helper", "util.go"), - node("f4", "Function", "Handler", "server.go"), // exported → excluded by default +func TestFilterIgnored_MatchesPattern(t *testing.T) { + candidates := []api.DeadCodeCandidate{ + {File: "src/generated/api.ts", Name: "fn1", Confidence: "high"}, + {File: "src/utils.ts", Name: "fn2", Confidence: "high"}, + } + got := filterIgnored(candidates, []string{"**/generated/**"}) + if len(got) != 1 || got[0].Name != "fn2" { + t.Errorf("unexpected filtered result: %+v", got) + } +} + +func TestFilterIgnored_MultiplePatterns(t *testing.T) { + candidates := []api.DeadCodeCandidate{ + {File: "src/generated/api.ts", Name: "fn1", Confidence: "high"}, + {File: "src/migrations/001.ts", Name: "fn2", Confidence: "high"}, + {File: "src/utils.ts", Name: "fn3", Confidence: "high"}, + } + got := filterIgnored(candidates, []string{"**/generated/**", "**/migrations/**"}) + if len(got) != 1 || got[0].Name != "fn3" { + t.Errorf("unexpected filtered result: %+v", got) + } +} + +func sampleResult() *api.DeadCodeResult { + return &api.DeadCodeResult{ + Metadata: api.DeadCodeMetadata{ + TotalDeclarations: 100, + DeadCodeCandidates: 3, + AliveCode: 80, + AnalysisMethod: "symbol_level_import_analysis", }, - Relationships: []api.Relationship{ - {ID: "r1", Type: "calls", StartNode: "f2", EndNode: "f1"}, // main → process + DeadCodeCandidates: []api.DeadCodeCandidate{ + {File: "src/utils.ts", Line: 8, Name: "unusedHelper", Type: "function", Confidence: "high", Reason: "No callers found"}, + {File: "src/old.ts", Line: 42, Name: "deprecated", Type: "function", Confidence: "medium", Reason: "Only called from dead code"}, + {File: "src/types.ts", Line: 0, Name: "OldInterface", Type: "type", Confidence: "low", Reason: "Type with no references"}, }, } +} - t.Run("default: excludes exports", func(t *testing.T) { - results := findDeadCode(g, false) - if len(results) != 1 { - t.Fatalf("expected 1 dead fn, got %d: %v", len(results), results) - } - if results[0].Name != "helper" { - t.Errorf("expected helper, got %q", results[0].Name) - } - }) +func TestPrintResults_Human(t *testing.T) { + var buf bytes.Buffer + if err := printResults(&buf, sampleResult(), ui.FormatHuman); err != nil { + t.Fatal(err) + } + out := buf.String() - t.Run("include exports", func(t *testing.T) { - results := findDeadCode(g, true) - if len(results) != 2 { - t.Fatalf("expected 2 dead fns, got %d: %v", len(results), results) + for _, want := range []string{ + "unusedHelper", "deprecated", "OldInterface", + "high", "medium", "low", + "No callers found", + "3 dead code candidate(s) out of 100 total declarations", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in output, got:\n%s", want, out) } - }) + } } -func node(id, label, name, file string) api.Node { - return api.Node{ - ID: id, - Labels: []string{label}, - Properties: map[string]any{ - "name": name, - "file": file, - }, +func TestPrintResults_HumanLineNumbers(t *testing.T) { + var buf bytes.Buffer + if err := printResults(&buf, sampleResult(), ui.FormatHuman); err != nil { + t.Fatal(err) + } + out := buf.String() + + // Line 8 and 42 should appear; line 0 should be blank. + if !strings.Contains(out, "8") { + t.Error("expected line number 8 in output") + } + if !strings.Contains(out, "42") { + t.Error("expected line number 42 in output") + } +} + +func TestPrintResults_Empty(t *testing.T) { + result := &api.DeadCodeResult{ + Metadata: api.DeadCodeMetadata{TotalDeclarations: 50}, + DeadCodeCandidates: nil, + } + + var buf bytes.Buffer + if err := printResults(&buf, result, ui.FormatHuman); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "No dead code detected") { + t.Errorf("expected 'No dead code detected', got:\n%s", buf.String()) + } +} + +func TestPrintResults_JSON(t *testing.T) { + var buf bytes.Buffer + if err := printResults(&buf, sampleResult(), ui.FormatJSON); err != nil { + t.Fatal(err) + } + + var decoded api.DeadCodeResult + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("invalid JSON output: %v", err) + } + if len(decoded.DeadCodeCandidates) != 3 { + t.Errorf("expected 3 candidates in JSON, got %d", len(decoded.DeadCodeCandidates)) + } + if decoded.Metadata.TotalDeclarations != 100 { + t.Errorf("expected totalDeclarations=100, got %d", decoded.Metadata.TotalDeclarations) } } diff --git a/internal/deadcode/zip.go b/internal/deadcode/zip.go new file mode 100644 index 0000000..06c82a7 --- /dev/null +++ b/internal/deadcode/zip.go @@ -0,0 +1,111 @@ +package deadcode + +import ( + "archive/zip" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// skipDirs are directory names that should never be included in the archive. +var skipDirs = map[string]bool{ + ".git": true, + "node_modules": true, + "vendor": true, + "__pycache__": true, + ".venv": true, + "venv": true, + "dist": true, + "build": true, + "target": true, + ".next": true, + ".nuxt": true, + "coverage": true, + ".terraform": true, + ".tox": true, +} + +// createZip archives the repository at dir into a temporary ZIP file and +// returns its path. The caller is responsible for removing the file. +// +// Strategy: use git archive when inside a Git repo (respects .gitignore, +// deterministic output). Falls back to a manual directory walk otherwise. +func createZip(dir string) (string, error) { + f, err := os.CreateTemp("", "supermodel-*.zip") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + dest := f.Name() + f.Close() + + if isGitRepo(dir) { + if err := gitArchive(dir, dest); err == nil { + return dest, nil + } + } + + if err := walkZip(dir, dest); err != nil { + os.Remove(dest) + return "", err + } + return dest, nil +} + +func isGitRepo(dir string) bool { + cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-dir") + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + return cmd.Run() == nil +} + +func gitArchive(dir, dest string) error { + cmd := exec.Command("git", "-C", dir, "archive", "--format=zip", "-o", dest, "HEAD") + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// walkZip creates a ZIP of dir, excluding skipDirs, hidden files, and +// files larger than 10 MB. +func walkZip(dir, dest string) error { + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + defer zw.Close() + + return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + if info.IsDir() { + if skipDirs[info.Name()] { + return filepath.SkipDir + } + return nil + } + if strings.HasPrefix(info.Name(), ".") || info.Size() > 10<<20 { + return nil + } + w, err := zw.Create(filepath.ToSlash(rel)) + if err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err + }) +}