From 0b81a203b3d63b08e1ccd5b51433a5c569bd8dc8 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 3 Apr 2026 15:31:57 -0400 Subject: [PATCH 1/4] feat: expand archive exclusions and apply skipDirs to git archive path Consolidate skipDirs across all 8 zip.go slices to 24 entries (was 11-14, already drifted). New exclusions: .claude, .idea, .vscode, .cache, .turbo, .nx, .pnpm-store, __snapshots__, bower_components, out. Add filterSkipDirs() post-filter on git archive output so exclusions apply to git repos too (previously skipDirs only affected the walkZip fallback). Fixes #31 (.claude/worktrees paths leaking into analysis). Partial #34 (archive exclusion rules). --- internal/analyze/zip.go | 93 +++++++++++++++++++++++++----- internal/archdocs/zip.go | 93 +++++++++++++++++++++++++----- internal/blastradius/zip.go | 93 +++++++++++++++++++++++++----- internal/deadcode/zip.go | 93 +++++++++++++++++++++++++----- internal/factory/zip.go | 93 +++++++++++++++++++++++++----- internal/find/zip.go | 106 +++++++++++++++++++++++++++++++--- internal/focus/zip.go | 111 ++++++++++++++++++++++++++++++++---- internal/mcp/zip.go | 108 +++++++++++++++++++++++++++++------ 8 files changed, 682 insertions(+), 108 deletions(-) diff --git a/internal/analyze/zip.go b/internal/analyze/zip.go index 6a6cc67..303537b 100644 --- a/internal/analyze/zip.go +++ b/internal/analyze/zip.go @@ -12,20 +12,30 @@ import ( // 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, + ".git": true, + ".claude": true, + ".idea": true, + ".vscode": true, + ".cache": true, + ".turbo": true, + ".nx": true, + ".next": true, + ".nuxt": true, + ".terraform": true, + ".tox": true, + ".venv": true, + ".pnpm-store": true, + "__pycache__": true, + "__snapshots__": true, + "bower_components": true, + "build": true, + "coverage": true, + "dist": true, + "node_modules": true, + "out": true, + "target": true, + "vendor": true, + "venv": true, } // createZip archives the repository at dir into a temporary ZIP file and @@ -33,6 +43,7 @@ var skipDirs = map[string]bool{ // // Strategy: use git archive when inside a Git repo (respects .gitignore, // deterministic output). Falls back to a manual directory walk otherwise. +// In both cases, skipDirs entries are excluded. func createZip(dir string) (string, error) { f, err := os.CreateTemp("", "supermodel-*.zip") if err != nil { @@ -43,6 +54,10 @@ func createZip(dir string) (string, error) { if isGitRepo(dir) { if err := gitArchive(dir, dest); err == nil { + if err := filterSkipDirs(dest); err != nil { + os.Remove(dest) + return "", fmt.Errorf("filter archive: %w", err) + } return dest, nil } } @@ -67,6 +82,56 @@ func gitArchive(dir, dest string) error { return cmd.Run() } +// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. +func filterSkipDirs(zipPath string) error { + data, err := os.ReadFile(zipPath) + if err != nil { + return err + } + + r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) + if err != nil { + return err + } + + out, err := os.Create(zipPath) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + for _, f := range r.File { + if shouldSkip(f.Name) { + continue + } + w, err := zw.Create(f.Name) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + _, err = io.Copy(w, rc) + rc.Close() + if err != nil { + return err + } + } + return zw.Close() +} + +// shouldSkip reports whether a zip entry path contains a skipDirs segment. +func shouldSkip(path string) bool { + for _, seg := range strings.Split(filepath.ToSlash(path), "/") { + if skipDirs[seg] { + return true + } + } + return false +} + // walkZip creates a ZIP of dir, excluding skipDirs, hidden files, and // files larger than 10 MB. func walkZip(dir, dest string) error { diff --git a/internal/archdocs/zip.go b/internal/archdocs/zip.go index 07066de..4802712 100644 --- a/internal/archdocs/zip.go +++ b/internal/archdocs/zip.go @@ -12,20 +12,30 @@ import ( // 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, + ".git": true, + ".claude": true, + ".idea": true, + ".vscode": true, + ".cache": true, + ".turbo": true, + ".nx": true, + ".next": true, + ".nuxt": true, + ".terraform": true, + ".tox": true, + ".venv": true, + ".pnpm-store": true, + "__pycache__": true, + "__snapshots__": true, + "bower_components": true, + "build": true, + "coverage": true, + "dist": true, + "node_modules": true, + "out": true, + "target": true, + "vendor": true, + "venv": true, } // createZip archives the repository at dir into a temporary ZIP file and @@ -33,6 +43,7 @@ var skipDirs = map[string]bool{ // // Strategy: use git archive when inside a Git repo (respects .gitignore, // deterministic output). Falls back to a manual directory walk otherwise. +// In both cases, skipDirs entries are excluded. func createZip(dir string) (string, error) { f, err := os.CreateTemp("", "supermodel-*.zip") if err != nil { @@ -43,6 +54,10 @@ func createZip(dir string) (string, error) { if isGitRepo(dir) { if err := gitArchive(dir, dest); err == nil { + if err := filterSkipDirs(dest); err != nil { + os.Remove(dest) + return "", fmt.Errorf("filter archive: %w", err) + } return dest, nil } } @@ -67,6 +82,56 @@ func gitArchive(dir, dest string) error { return cmd.Run() } +// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. +func filterSkipDirs(zipPath string) error { + data, err := os.ReadFile(zipPath) + if err != nil { + return err + } + + r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) + if err != nil { + return err + } + + out, err := os.Create(zipPath) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + for _, f := range r.File { + if shouldSkip(f.Name) { + continue + } + w, err := zw.Create(f.Name) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + _, err = io.Copy(w, rc) + rc.Close() + if err != nil { + return err + } + } + return zw.Close() +} + +// shouldSkip reports whether a zip entry path contains a skipDirs segment. +func shouldSkip(path string) bool { + for _, seg := range strings.Split(filepath.ToSlash(path), "/") { + if skipDirs[seg] { + return true + } + } + return false +} + // walkZip creates a ZIP of dir, excluding skipDirs, hidden files, and // files larger than 10 MB. func walkZip(dir, dest string) error { diff --git a/internal/blastradius/zip.go b/internal/blastradius/zip.go index 4769966..2c40b1a 100644 --- a/internal/blastradius/zip.go +++ b/internal/blastradius/zip.go @@ -12,20 +12,30 @@ import ( // 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, + ".git": true, + ".claude": true, + ".idea": true, + ".vscode": true, + ".cache": true, + ".turbo": true, + ".nx": true, + ".next": true, + ".nuxt": true, + ".terraform": true, + ".tox": true, + ".venv": true, + ".pnpm-store": true, + "__pycache__": true, + "__snapshots__": true, + "bower_components": true, + "build": true, + "coverage": true, + "dist": true, + "node_modules": true, + "out": true, + "target": true, + "vendor": true, + "venv": true, } // createZip archives the repository at dir into a temporary ZIP file and @@ -33,6 +43,7 @@ var skipDirs = map[string]bool{ // // Strategy: use git archive when inside a Git repo (respects .gitignore, // deterministic output). Falls back to a manual directory walk otherwise. +// In both cases, skipDirs entries are excluded. func createZip(dir string) (string, error) { f, err := os.CreateTemp("", "supermodel-*.zip") if err != nil { @@ -43,6 +54,10 @@ func createZip(dir string) (string, error) { if isGitRepo(dir) { if err := gitArchive(dir, dest); err == nil { + if err := filterSkipDirs(dest); err != nil { + os.Remove(dest) + return "", fmt.Errorf("filter archive: %w", err) + } return dest, nil } } @@ -67,6 +82,56 @@ func gitArchive(dir, dest string) error { return cmd.Run() } +// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. +func filterSkipDirs(zipPath string) error { + data, err := os.ReadFile(zipPath) + if err != nil { + return err + } + + r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) + if err != nil { + return err + } + + out, err := os.Create(zipPath) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + for _, f := range r.File { + if shouldSkip(f.Name) { + continue + } + w, err := zw.Create(f.Name) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + _, err = io.Copy(w, rc) + rc.Close() + if err != nil { + return err + } + } + return zw.Close() +} + +// shouldSkip reports whether a zip entry path contains a skipDirs segment. +func shouldSkip(path string) bool { + for _, seg := range strings.Split(filepath.ToSlash(path), "/") { + if skipDirs[seg] { + return true + } + } + return false +} + // walkZip creates a ZIP of dir, excluding skipDirs, hidden files, and // files larger than 10 MB. func walkZip(dir, dest string) error { diff --git a/internal/deadcode/zip.go b/internal/deadcode/zip.go index 06c82a7..d797493 100644 --- a/internal/deadcode/zip.go +++ b/internal/deadcode/zip.go @@ -12,20 +12,30 @@ import ( // 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, + ".git": true, + ".claude": true, + ".idea": true, + ".vscode": true, + ".cache": true, + ".turbo": true, + ".nx": true, + ".next": true, + ".nuxt": true, + ".terraform": true, + ".tox": true, + ".venv": true, + ".pnpm-store": true, + "__pycache__": true, + "__snapshots__": true, + "bower_components": true, + "build": true, + "coverage": true, + "dist": true, + "node_modules": true, + "out": true, + "target": true, + "vendor": true, + "venv": true, } // createZip archives the repository at dir into a temporary ZIP file and @@ -33,6 +43,7 @@ var skipDirs = map[string]bool{ // // Strategy: use git archive when inside a Git repo (respects .gitignore, // deterministic output). Falls back to a manual directory walk otherwise. +// In both cases, skipDirs entries are excluded. func createZip(dir string) (string, error) { f, err := os.CreateTemp("", "supermodel-*.zip") if err != nil { @@ -43,6 +54,10 @@ func createZip(dir string) (string, error) { if isGitRepo(dir) { if err := gitArchive(dir, dest); err == nil { + if err := filterSkipDirs(dest); err != nil { + os.Remove(dest) + return "", fmt.Errorf("filter archive: %w", err) + } return dest, nil } } @@ -67,6 +82,56 @@ func gitArchive(dir, dest string) error { return cmd.Run() } +// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. +func filterSkipDirs(zipPath string) error { + data, err := os.ReadFile(zipPath) + if err != nil { + return err + } + + r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) + if err != nil { + return err + } + + out, err := os.Create(zipPath) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + for _, f := range r.File { + if shouldSkip(f.Name) { + continue + } + w, err := zw.Create(f.Name) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + _, err = io.Copy(w, rc) + rc.Close() + if err != nil { + return err + } + } + return zw.Close() +} + +// shouldSkip reports whether a zip entry path contains a skipDirs segment. +func shouldSkip(path string) bool { + for _, seg := range strings.Split(filepath.ToSlash(path), "/") { + if skipDirs[seg] { + return true + } + } + return false +} + // walkZip creates a ZIP of dir, excluding skipDirs, hidden files, and // files larger than 10 MB. func walkZip(dir, dest string) error { diff --git a/internal/factory/zip.go b/internal/factory/zip.go index 8e49ef3..1920832 100644 --- a/internal/factory/zip.go +++ b/internal/factory/zip.go @@ -11,20 +11,30 @@ import ( // 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, + ".git": true, + ".claude": true, + ".idea": true, + ".vscode": true, + ".cache": true, + ".turbo": true, + ".nx": true, + ".next": true, + ".nuxt": true, + ".terraform": true, + ".tox": true, + ".venv": true, + ".pnpm-store": true, + "__pycache__": true, + "__snapshots__": true, + "bower_components": true, + "build": true, + "coverage": true, + "dist": true, + "node_modules": true, + "out": true, + "target": true, + "vendor": true, + "venv": true, } // CreateZip archives the repository at dir into a temporary ZIP file and @@ -33,6 +43,7 @@ var skipDirs = map[string]bool{ // Strategy: use git archive when the repo is clean (committed state matches // working tree, so the archive reflects what the user is actually looking at). // Falls back to a manual directory walk otherwise. +// In both cases, skipDirs entries are excluded. func CreateZip(dir string) (string, error) { f, err := os.CreateTemp("", "supermodel-factory-*.zip") if err != nil { @@ -43,6 +54,10 @@ func CreateZip(dir string) (string, error) { if isGitRepo(dir) && isWorktreeClean(dir) { if err := gitArchive(dir, dest); err == nil { + if err := filterSkipDirs(dest); err != nil { + _ = os.Remove(dest) + return "", err + } return dest, nil } } @@ -73,6 +88,56 @@ func gitArchive(dir, dest string) error { return cmd.Run() } +// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. +func filterSkipDirs(zipPath string) error { + data, err := os.ReadFile(zipPath) //nolint:gosec // zipPath is a temp file from os.CreateTemp + if err != nil { + return err + } + + r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) + if err != nil { + return err + } + + out, err := os.Create(zipPath) //nolint:gosec // zipPath is a temp file + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + for _, f := range r.File { + if shouldSkip(f.Name) { + continue + } + w, err := zw.Create(f.Name) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + _, err = io.Copy(w, rc) + rc.Close() + if err != nil { + return err + } + } + return zw.Close() +} + +// shouldSkip reports whether a zip entry path contains a skipDirs segment. +func shouldSkip(path string) bool { + for _, seg := range strings.Split(filepath.ToSlash(path), "/") { + if skipDirs[seg] { + return true + } + } + return false +} + // walkZip creates a ZIP of dir, excluding skipDirs, hidden files, symlinks, // and files larger than 10 MB. func walkZip(dir, dest string) error { diff --git a/internal/find/zip.go b/internal/find/zip.go index 0225c57..737be32 100644 --- a/internal/find/zip.go +++ b/internal/find/zip.go @@ -1,7 +1,5 @@ package find -// zip.go duplicates archive helpers to preserve vertical slice isolation. - import ( "archive/zip" "fmt" @@ -12,25 +10,58 @@ import ( "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, ".terraform": true, + ".git": true, + ".claude": true, + ".idea": true, + ".vscode": true, + ".cache": true, + ".turbo": true, + ".nx": true, + ".next": true, + ".nuxt": true, + ".terraform": true, + ".tox": true, + ".venv": true, + ".pnpm-store": true, + "__pycache__": true, + "__snapshots__": true, + "bower_components": true, + "build": true, + "coverage": true, + "dist": true, + "node_modules": true, + "out": true, + "target": true, + "vendor": true, + "venv": 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. +// In both cases, skipDirs entries are excluded. func createZip(dir string) (string, error) { - f, err := os.CreateTemp("", "supermodel-find-*.zip") + f, err := os.CreateTemp("", "supermodel-*.zip") if err != nil { - return "", fmt.Errorf("create temp: %w", err) + return "", fmt.Errorf("create temp file: %w", err) } dest := f.Name() f.Close() + if isGitRepo(dir) { if err := gitArchive(dir, dest); err == nil { + if err := filterSkipDirs(dest); err != nil { + os.Remove(dest) + return "", fmt.Errorf("filter archive: %w", err) + } return dest, nil } } + if err := walkZip(dir, dest); err != nil { os.Remove(dest) return "", err @@ -51,19 +82,76 @@ func gitArchive(dir, dest string) error { return cmd.Run() } +// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. +func filterSkipDirs(zipPath string) error { + data, err := os.ReadFile(zipPath) + if err != nil { + return err + } + + r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) + if err != nil { + return err + } + + out, err := os.Create(zipPath) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + for _, f := range r.File { + if shouldSkip(f.Name) { + continue + } + w, err := zw.Create(f.Name) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + _, err = io.Copy(w, rc) + rc.Close() + if err != nil { + return err + } + } + return zw.Close() +} + +// shouldSkip reports whether a zip entry path contains a skipDirs segment. +func shouldSkip(path string) bool { + for _, seg := range strings.Split(filepath.ToSlash(path), "/") { + if skipDirs[seg] { + return true + } + } + return false +} + +// 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, _ := filepath.Rel(dir, path) + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } if info.IsDir() { if skipDirs[info.Name()] { return filepath.SkipDir diff --git a/internal/focus/zip.go b/internal/focus/zip.go index 84907d2..a38fb46 100644 --- a/internal/focus/zip.go +++ b/internal/focus/zip.go @@ -1,40 +1,70 @@ package focus -// zip.go duplicates the archive helpers from analyze/zip.go to preserve -// vertical slice isolation (focus must not import the analyze slice). - import ( "archive/zip" "fmt" "io" "os" "os/exec" - "path/filepath" - "strings" "github.com/supermodeltools/cli/internal/api" "github.com/supermodeltools/cli/internal/config" + "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, ".terraform": true, + ".git": true, + ".claude": true, + ".idea": true, + ".vscode": true, + ".cache": true, + ".turbo": true, + ".nx": true, + ".next": true, + ".nuxt": true, + ".terraform": true, + ".tox": true, + ".venv": true, + ".pnpm-store": true, + "__pycache__": true, + "__snapshots__": true, + "bower_components": true, + "build": true, + "coverage": true, + "dist": true, + "node_modules": true, + "out": true, + "target": true, + "vendor": true, + "venv": 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. +// In both cases, skipDirs entries are excluded. func createZip(dir string) (string, error) { - f, err := os.CreateTemp("", "supermodel-focus-*.zip") + f, err := os.CreateTemp("", "supermodel-*.zip") if err != nil { - return "", fmt.Errorf("create temp: %w", err) + return "", fmt.Errorf("create temp file: %w", err) } dest := f.Name() f.Close() + if isGitRepo(dir) { if err := gitArchive(dir, dest); err == nil { + if err := filterSkipDirs(dest); err != nil { + os.Remove(dest) + return "", fmt.Errorf("filter archive: %w", err) + } return dest, nil } } + if err := walkZip(dir, dest); err != nil { os.Remove(dest) return "", err @@ -55,19 +85,76 @@ func gitArchive(dir, dest string) error { return cmd.Run() } +// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. +func filterSkipDirs(zipPath string) error { + data, err := os.ReadFile(zipPath) + if err != nil { + return err + } + + r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) + if err != nil { + return err + } + + out, err := os.Create(zipPath) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + for _, f := range r.File { + if shouldSkip(f.Name) { + continue + } + w, err := zw.Create(f.Name) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + _, err = io.Copy(w, rc) + rc.Close() + if err != nil { + return err + } + } + return zw.Close() +} + +// shouldSkip reports whether a zip entry path contains a skipDirs segment. +func shouldSkip(path string) bool { + for _, seg := range strings.Split(filepath.ToSlash(path), "/") { + if skipDirs[seg] { + return true + } + } + return false +} + +// 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, _ := filepath.Rel(dir, path) + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } if info.IsDir() { if skipDirs[info.Name()] { return filepath.SkipDir diff --git a/internal/mcp/zip.go b/internal/mcp/zip.go index 756cfc3..97a3b67 100644 --- a/internal/mcp/zip.go +++ b/internal/mcp/zip.go @@ -1,10 +1,5 @@ package mcp -// createZip is a thin shim so the mcp package can archive the repo -// without importing the analyze slice. It delegates to git archive -// and falls back to a directory walk — identical logic to analyze/zip.go -// but duplicated to preserve vertical slice isolation. - import ( "archive/zip" "fmt" @@ -15,22 +10,42 @@ import ( "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, - ".terraform": true, + ".git": true, + ".claude": true, + ".idea": true, + ".vscode": true, + ".cache": true, + ".turbo": true, + ".nx": true, + ".next": true, + ".nuxt": true, + ".terraform": true, + ".tox": true, + ".venv": true, + ".pnpm-store": true, + "__pycache__": true, + "__snapshots__": true, + "bower_components": true, + "build": true, + "coverage": true, + "dist": true, + "node_modules": true, + "out": true, + "target": true, + "vendor": true, + "venv": 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. +// In both cases, skipDirs entries are excluded. func createZip(dir string) (string, error) { - f, err := os.CreateTemp("", "supermodel-mcp-*.zip") + f, err := os.CreateTemp("", "supermodel-*.zip") if err != nil { return "", fmt.Errorf("create temp file: %w", err) } @@ -39,9 +54,14 @@ func createZip(dir string) (string, error) { if isGitRepo(dir) { if err := gitArchive(dir, dest); err == nil { + if err := filterSkipDirs(dest); err != nil { + os.Remove(dest) + return "", fmt.Errorf("filter archive: %w", err) + } return dest, nil } } + if err := walkZip(dir, dest); err != nil { os.Remove(dest) return "", err @@ -62,14 +82,68 @@ func gitArchive(dir, dest string) error { return cmd.Run() } +// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. +func filterSkipDirs(zipPath string) error { + data, err := os.ReadFile(zipPath) + if err != nil { + return err + } + + r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) + if err != nil { + return err + } + + out, err := os.Create(zipPath) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + for _, f := range r.File { + if shouldSkip(f.Name) { + continue + } + w, err := zw.Create(f.Name) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + _, err = io.Copy(w, rc) + rc.Close() + if err != nil { + return err + } + } + return zw.Close() +} + +// shouldSkip reports whether a zip entry path contains a skipDirs segment. +func shouldSkip(path string) bool { + for _, seg := range strings.Split(filepath.ToSlash(path), "/") { + if skipDirs[seg] { + return true + } + } + return false +} + +// 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 From 71c7e91ffeaea159b872a5d1b6c6923c8f4648b9 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 3 Apr 2026 15:36:00 -0400 Subject: [PATCH 2/4] refactor: extract shared archive package, eliminate 8 duplicate zip.go files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move SkipDirs, CreateZip, WalkZip, FilterSkipDirs, GitArchive into internal/archive/ (shared kernel). Each slice's zip.go is now a thin wrapper calling archive.CreateZip(). Factory keeps its isWorktreeClean logic locally. Single source of truth for exclusions — no more drift across slices. Net -1120 lines. --- internal/analyze/zip.go | 173 +-------------------------------- internal/analyze/zip_test.go | 10 +- internal/archdocs/zip.go | 173 +-------------------------------- internal/archive/archive.go | 179 +++++++++++++++++++++++++++++++++++ internal/archive/doc.go | 6 ++ internal/blastradius/zip.go | 173 +-------------------------------- internal/deadcode/zip.go | 173 +-------------------------------- internal/factory/zip.go | 145 ++-------------------------- internal/factory/zip_test.go | 16 ++-- internal/find/zip.go | 173 +-------------------------------- internal/find/zip_test.go | 10 +- internal/focus/zip.go | 172 +-------------------------------- internal/mcp/zip.go | 173 +-------------------------------- 13 files changed, 228 insertions(+), 1348 deletions(-) create mode 100644 internal/archive/archive.go create mode 100644 internal/archive/doc.go diff --git a/internal/analyze/zip.go b/internal/analyze/zip.go index 303537b..ba7a223 100644 --- a/internal/analyze/zip.go +++ b/internal/analyze/zip.go @@ -1,176 +1,7 @@ package analyze -import ( - "archive/zip" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" -) +import "github.com/supermodeltools/cli/internal/archive" -// skipDirs are directory names that should never be included in the archive. -var skipDirs = map[string]bool{ - ".git": true, - ".claude": true, - ".idea": true, - ".vscode": true, - ".cache": true, - ".turbo": true, - ".nx": true, - ".next": true, - ".nuxt": true, - ".terraform": true, - ".tox": true, - ".venv": true, - ".pnpm-store": true, - "__pycache__": true, - "__snapshots__": true, - "bower_components": true, - "build": true, - "coverage": true, - "dist": true, - "node_modules": true, - "out": true, - "target": true, - "vendor": true, - "venv": 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. -// In both cases, skipDirs entries are excluded. 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 { - if err := filterSkipDirs(dest); err != nil { - os.Remove(dest) - return "", fmt.Errorf("filter archive: %w", err) - } - 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() -} - -// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. -func filterSkipDirs(zipPath string) error { - data, err := os.ReadFile(zipPath) - if err != nil { - return err - } - - r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) - if err != nil { - return err - } - - out, err := os.Create(zipPath) - if err != nil { - return err - } - defer out.Close() - - zw := zip.NewWriter(out) - for _, f := range r.File { - if shouldSkip(f.Name) { - continue - } - w, err := zw.Create(f.Name) - if err != nil { - return err - } - rc, err := f.Open() - if err != nil { - return err - } - _, err = io.Copy(w, rc) - rc.Close() - if err != nil { - return err - } - } - return zw.Close() -} - -// shouldSkip reports whether a zip entry path contains a skipDirs segment. -func shouldSkip(path string) bool { - for _, seg := range strings.Split(filepath.ToSlash(path), "/") { - if skipDirs[seg] { - return true - } - } - return false -} - -// 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 - }) + return archive.CreateZip(dir) } diff --git a/internal/analyze/zip_test.go b/internal/analyze/zip_test.go index dac6aa0..66f3fb5 100644 --- a/internal/analyze/zip_test.go +++ b/internal/analyze/zip_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/supermodeltools/cli/internal/archive" ) func TestIsGitRepo_WithDotGit(t *testing.T) { @@ -17,7 +19,7 @@ func TestIsGitRepo_WithDotGit(t *testing.T) { // isGitRepo uses `git rev-parse --git-dir` which needs an actual git repo; // fall back to checking directory creation only — the factory version // (os.Stat) is simpler, but here we just ensure non-git dir returns false. - if isGitRepo(t.TempDir()) { + if archive.IsGitRepo(t.TempDir()) { t.Error("empty temp dir should not be a git repo") } } @@ -29,7 +31,7 @@ func TestWalkZip_IncludesFiles(t *testing.T) { } dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatalf("walkZip: %v", err) } entries := readZipEntries(t, dest) @@ -48,7 +50,7 @@ func TestWalkZip_SkipsHiddenFiles(t *testing.T) { } dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatal(err) } entries := readZipEntries(t, dest) @@ -74,7 +76,7 @@ func TestWalkZip_SkipsSkipDirs(t *testing.T) { } dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatal(err) } entries := readZipEntries(t, dest) diff --git a/internal/archdocs/zip.go b/internal/archdocs/zip.go index 4802712..f0dbf18 100644 --- a/internal/archdocs/zip.go +++ b/internal/archdocs/zip.go @@ -1,176 +1,7 @@ package archdocs -import ( - "archive/zip" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" -) +import "github.com/supermodeltools/cli/internal/archive" -// skipDirs are directory names that should never be included in the archive. -var skipDirs = map[string]bool{ - ".git": true, - ".claude": true, - ".idea": true, - ".vscode": true, - ".cache": true, - ".turbo": true, - ".nx": true, - ".next": true, - ".nuxt": true, - ".terraform": true, - ".tox": true, - ".venv": true, - ".pnpm-store": true, - "__pycache__": true, - "__snapshots__": true, - "bower_components": true, - "build": true, - "coverage": true, - "dist": true, - "node_modules": true, - "out": true, - "target": true, - "vendor": true, - "venv": 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. -// In both cases, skipDirs entries are excluded. 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 { - if err := filterSkipDirs(dest); err != nil { - os.Remove(dest) - return "", fmt.Errorf("filter archive: %w", err) - } - 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() -} - -// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. -func filterSkipDirs(zipPath string) error { - data, err := os.ReadFile(zipPath) - if err != nil { - return err - } - - r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) - if err != nil { - return err - } - - out, err := os.Create(zipPath) - if err != nil { - return err - } - defer out.Close() - - zw := zip.NewWriter(out) - for _, f := range r.File { - if shouldSkip(f.Name) { - continue - } - w, err := zw.Create(f.Name) - if err != nil { - return err - } - rc, err := f.Open() - if err != nil { - return err - } - _, err = io.Copy(w, rc) - rc.Close() - if err != nil { - return err - } - } - return zw.Close() -} - -// shouldSkip reports whether a zip entry path contains a skipDirs segment. -func shouldSkip(path string) bool { - for _, seg := range strings.Split(filepath.ToSlash(path), "/") { - if skipDirs[seg] { - return true - } - } - return false -} - -// 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 - }) + return archive.CreateZip(dir) } diff --git a/internal/archive/archive.go b/internal/archive/archive.go new file mode 100644 index 0000000..6414a1c --- /dev/null +++ b/internal/archive/archive.go @@ -0,0 +1,179 @@ +package archive + +import ( + "archive/zip" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// SkipDirs are directory names that should never be included in the archive. +// This is the single source of truth — all slices import it. +var SkipDirs = map[string]bool{ + ".git": true, + ".claude": true, + ".idea": true, + ".vscode": true, + ".cache": true, + ".turbo": true, + ".nx": true, + ".next": true, + ".nuxt": true, + ".terraform": true, + ".tox": true, + ".venv": true, + ".pnpm-store": true, + "__pycache__": true, + "__snapshots__": true, + "bower_components": true, + "build": true, + "coverage": true, + "dist": true, + "node_modules": true, + "out": true, + "target": true, + "vendor": true, + "venv": 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. +// In both cases, SkipDirs entries are excluded. +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 { + if err := FilterSkipDirs(dest); err != nil { + os.Remove(dest) + return "", fmt.Errorf("filter archive: %w", err) + } + return dest, nil + } + } + + if err := WalkZip(dir, dest); err != nil { + os.Remove(dest) + return "", err + } + return dest, nil +} + +// IsGitRepo reports whether dir is inside a Git repository. +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 +} + +// GitArchive creates a ZIP of the HEAD commit using git archive. +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() +} + +// FilterSkipDirs removes entries from a ZIP whose path contains a SkipDirs segment. +func FilterSkipDirs(zipPath string) error { + data, err := os.ReadFile(zipPath) + if err != nil { + return err + } + + r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) + if err != nil { + return err + } + + out, err := os.Create(zipPath) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + for _, f := range r.File { + if ShouldSkip(f.Name) { + continue + } + w, err := zw.Create(f.Name) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + _, err = io.Copy(w, rc) + rc.Close() + if err != nil { + return err + } + } + return zw.Close() +} + +// ShouldSkip reports whether a zip entry path contains a SkipDirs segment. +func ShouldSkip(path string) bool { + for _, seg := range strings.Split(filepath.ToSlash(path), "/") { + if SkipDirs[seg] { + return true + } + } + return false +} + +// 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 + }) +} diff --git a/internal/archive/doc.go b/internal/archive/doc.go new file mode 100644 index 0000000..23416d9 --- /dev/null +++ b/internal/archive/doc.go @@ -0,0 +1,6 @@ +// Package archive provides shared ZIP creation utilities for Supermodel CLI. +// +// This is a shared kernel package. Slice packages under internal/ may import +// it freely, just like internal/api, internal/cache, internal/config, and +// internal/ui. +package archive diff --git a/internal/blastradius/zip.go b/internal/blastradius/zip.go index 2c40b1a..69b0300 100644 --- a/internal/blastradius/zip.go +++ b/internal/blastradius/zip.go @@ -1,176 +1,7 @@ package blastradius -import ( - "archive/zip" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" -) +import "github.com/supermodeltools/cli/internal/archive" -// skipDirs are directory names that should never be included in the archive. -var skipDirs = map[string]bool{ - ".git": true, - ".claude": true, - ".idea": true, - ".vscode": true, - ".cache": true, - ".turbo": true, - ".nx": true, - ".next": true, - ".nuxt": true, - ".terraform": true, - ".tox": true, - ".venv": true, - ".pnpm-store": true, - "__pycache__": true, - "__snapshots__": true, - "bower_components": true, - "build": true, - "coverage": true, - "dist": true, - "node_modules": true, - "out": true, - "target": true, - "vendor": true, - "venv": 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. -// In both cases, skipDirs entries are excluded. 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 { - if err := filterSkipDirs(dest); err != nil { - os.Remove(dest) - return "", fmt.Errorf("filter archive: %w", err) - } - 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() -} - -// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. -func filterSkipDirs(zipPath string) error { - data, err := os.ReadFile(zipPath) - if err != nil { - return err - } - - r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) - if err != nil { - return err - } - - out, err := os.Create(zipPath) - if err != nil { - return err - } - defer out.Close() - - zw := zip.NewWriter(out) - for _, f := range r.File { - if shouldSkip(f.Name) { - continue - } - w, err := zw.Create(f.Name) - if err != nil { - return err - } - rc, err := f.Open() - if err != nil { - return err - } - _, err = io.Copy(w, rc) - rc.Close() - if err != nil { - return err - } - } - return zw.Close() -} - -// shouldSkip reports whether a zip entry path contains a skipDirs segment. -func shouldSkip(path string) bool { - for _, seg := range strings.Split(filepath.ToSlash(path), "/") { - if skipDirs[seg] { - return true - } - } - return false -} - -// 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 - }) + return archive.CreateZip(dir) } diff --git a/internal/deadcode/zip.go b/internal/deadcode/zip.go index d797493..3355e16 100644 --- a/internal/deadcode/zip.go +++ b/internal/deadcode/zip.go @@ -1,176 +1,7 @@ package deadcode -import ( - "archive/zip" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" -) +import "github.com/supermodeltools/cli/internal/archive" -// skipDirs are directory names that should never be included in the archive. -var skipDirs = map[string]bool{ - ".git": true, - ".claude": true, - ".idea": true, - ".vscode": true, - ".cache": true, - ".turbo": true, - ".nx": true, - ".next": true, - ".nuxt": true, - ".terraform": true, - ".tox": true, - ".venv": true, - ".pnpm-store": true, - "__pycache__": true, - "__snapshots__": true, - "bower_components": true, - "build": true, - "coverage": true, - "dist": true, - "node_modules": true, - "out": true, - "target": true, - "vendor": true, - "venv": 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. -// In both cases, skipDirs entries are excluded. 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 { - if err := filterSkipDirs(dest); err != nil { - os.Remove(dest) - return "", fmt.Errorf("filter archive: %w", err) - } - 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() -} - -// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. -func filterSkipDirs(zipPath string) error { - data, err := os.ReadFile(zipPath) - if err != nil { - return err - } - - r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) - if err != nil { - return err - } - - out, err := os.Create(zipPath) - if err != nil { - return err - } - defer out.Close() - - zw := zip.NewWriter(out) - for _, f := range r.File { - if shouldSkip(f.Name) { - continue - } - w, err := zw.Create(f.Name) - if err != nil { - return err - } - rc, err := f.Open() - if err != nil { - return err - } - _, err = io.Copy(w, rc) - rc.Close() - if err != nil { - return err - } - } - return zw.Close() -} - -// shouldSkip reports whether a zip entry path contains a skipDirs segment. -func shouldSkip(path string) bool { - for _, seg := range strings.Split(filepath.ToSlash(path), "/") { - if skipDirs[seg] { - return true - } - } - return false -} - -// 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 - }) + return archive.CreateZip(dir) } diff --git a/internal/factory/zip.go b/internal/factory/zip.go index 1920832..4b4ca9d 100644 --- a/internal/factory/zip.go +++ b/internal/factory/zip.go @@ -1,49 +1,20 @@ package factory import ( - "archive/zip" "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, - ".claude": true, - ".idea": true, - ".vscode": true, - ".cache": true, - ".turbo": true, - ".nx": true, - ".next": true, - ".nuxt": true, - ".terraform": true, - ".tox": true, - ".venv": true, - ".pnpm-store": true, - "__pycache__": true, - "__snapshots__": true, - "bower_components": true, - "build": true, - "coverage": true, - "dist": true, - "node_modules": true, - "out": true, - "target": true, - "vendor": true, - "venv": true, -} + "github.com/supermodeltools/cli/internal/archive" +) // 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 the repo is clean (committed state matches -// working tree, so the archive reflects what the user is actually looking at). -// Falls back to a manual directory walk otherwise. -// In both cases, skipDirs entries are excluded. +// Uses git archive only when the worktree is clean (so the archive reflects +// what the user is actually looking at). Falls back to directory walk otherwise. func CreateZip(dir string) (string, error) { f, err := os.CreateTemp("", "supermodel-factory-*.zip") if err != nil { @@ -53,8 +24,8 @@ func CreateZip(dir string) (string, error) { f.Close() if isGitRepo(dir) && isWorktreeClean(dir) { - if err := gitArchive(dir, dest); err == nil { - if err := filterSkipDirs(dest); err != nil { + if err := archive.GitArchive(dir, dest); err == nil { + if err := archive.FilterSkipDirs(dest); err != nil { _ = os.Remove(dest) return "", err } @@ -62,7 +33,7 @@ func CreateZip(dir string) (string, error) { } } - if err := walkZip(dir, dest); err != nil { + if err := archive.WalkZip(dir, dest); err != nil { _ = os.Remove(dest) return "", err } @@ -74,113 +45,13 @@ func isGitRepo(dir string) bool { return err == nil } -// isWorktreeClean reports whether there are no uncommitted changes. When the -// worktree is dirty, git archive HEAD would silently omit local edits, so we -// fall back to the directory walk instead. func isWorktreeClean(dir string) bool { out, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output() //nolint:gosec // dir is user-supplied cwd return err == nil && strings.TrimSpace(string(out)) == "" } -func gitArchive(dir, dest string) error { - cmd := exec.Command("git", "-C", dir, "archive", "--format=zip", "-o", dest, "HEAD") //nolint:gosec // dir is user-supplied cwd; dest is temp file - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. -func filterSkipDirs(zipPath string) error { - data, err := os.ReadFile(zipPath) //nolint:gosec // zipPath is a temp file from os.CreateTemp - if err != nil { - return err - } - - r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) - if err != nil { - return err - } - - out, err := os.Create(zipPath) //nolint:gosec // zipPath is a temp file - if err != nil { - return err - } - defer out.Close() - - zw := zip.NewWriter(out) - for _, f := range r.File { - if shouldSkip(f.Name) { - continue - } - w, err := zw.Create(f.Name) - if err != nil { - return err - } - rc, err := f.Open() - if err != nil { - return err - } - _, err = io.Copy(w, rc) - rc.Close() - if err != nil { - return err - } - } - return zw.Close() -} - -// shouldSkip reports whether a zip entry path contains a skipDirs segment. -func shouldSkip(path string) bool { - for _, seg := range strings.Split(filepath.ToSlash(path), "/") { - if skipDirs[seg] { - return true - } - } - return false -} - -// walkZip creates a ZIP of dir, excluding skipDirs, hidden files, symlinks, -// and files larger than 10 MB. -func walkZip(dir, dest string) error { - out, err := os.Create(dest) //nolint:gosec // dest is a temp file path from os.CreateTemp - 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 - } - // Skip symlinks: os.Open follows them, which could read files outside dir. - if info.Mode()&os.ModeSymlink != 0 { - return nil - } - 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 - } - return copyFile(path, w) - }) -} - func copyFile(path string, w io.Writer) error { - f, err := os.Open(path) //nolint:gosec // path is from filepath.Walk within dir; symlinks already excluded above + f, err := os.Open(path) //nolint:gosec // path is from filepath.Walk; symlinks excluded by caller if err != nil { return err } diff --git a/internal/factory/zip_test.go b/internal/factory/zip_test.go index 5d2ebc2..05bc386 100644 --- a/internal/factory/zip_test.go +++ b/internal/factory/zip_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/supermodeltools/cli/internal/archive" ) // ── isGitRepo ───────────────────────────────────────────────────────────────── @@ -35,7 +37,7 @@ func TestWalkZip_IncludesRegularFiles(t *testing.T) { writeFile(t, filepath.Join(src, "README.md"), "# readme") dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatalf("walkZip: %v", err) } @@ -54,7 +56,7 @@ func TestWalkZip_SkipsHiddenFiles(t *testing.T) { writeFile(t, filepath.Join(src, "main.go"), "package main") dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatalf("walkZip: %v", err) } @@ -77,7 +79,7 @@ func TestWalkZip_SkipsNodeModules(t *testing.T) { writeFile(t, filepath.Join(src, "index.js"), "console.log('hi')") dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatalf("walkZip: %v", err) } @@ -94,7 +96,7 @@ func TestWalkZip_SkipsNodeModules(t *testing.T) { func TestWalkZip_SkipsAllSkipDirs(t *testing.T) { src := t.TempDir() - for dir := range skipDirs { + for dir := range archive.SkipDirs { d := filepath.Join(src, dir) if err := os.Mkdir(d, 0750); err != nil { t.Fatal(err) @@ -104,7 +106,7 @@ func TestWalkZip_SkipsAllSkipDirs(t *testing.T) { writeFile(t, filepath.Join(src, "real.go"), "package main") dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatalf("walkZip: %v", err) } @@ -120,7 +122,7 @@ func TestWalkZip_SkipsAllSkipDirs(t *testing.T) { func TestWalkZip_EmptyDir(t *testing.T) { src := t.TempDir() dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatalf("walkZip on empty dir: %v", err) } entries := zipEntries(t, dest) @@ -138,7 +140,7 @@ func TestWalkZip_NestedFiles(t *testing.T) { writeFile(t, filepath.Join(sub, "client.go"), "package api") dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatalf("walkZip: %v", err) } diff --git a/internal/find/zip.go b/internal/find/zip.go index 737be32..871867f 100644 --- a/internal/find/zip.go +++ b/internal/find/zip.go @@ -1,176 +1,7 @@ package find -import ( - "archive/zip" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" -) +import "github.com/supermodeltools/cli/internal/archive" -// skipDirs are directory names that should never be included in the archive. -var skipDirs = map[string]bool{ - ".git": true, - ".claude": true, - ".idea": true, - ".vscode": true, - ".cache": true, - ".turbo": true, - ".nx": true, - ".next": true, - ".nuxt": true, - ".terraform": true, - ".tox": true, - ".venv": true, - ".pnpm-store": true, - "__pycache__": true, - "__snapshots__": true, - "bower_components": true, - "build": true, - "coverage": true, - "dist": true, - "node_modules": true, - "out": true, - "target": true, - "vendor": true, - "venv": 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. -// In both cases, skipDirs entries are excluded. 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 { - if err := filterSkipDirs(dest); err != nil { - os.Remove(dest) - return "", fmt.Errorf("filter archive: %w", err) - } - 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() -} - -// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. -func filterSkipDirs(zipPath string) error { - data, err := os.ReadFile(zipPath) - if err != nil { - return err - } - - r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) - if err != nil { - return err - } - - out, err := os.Create(zipPath) - if err != nil { - return err - } - defer out.Close() - - zw := zip.NewWriter(out) - for _, f := range r.File { - if shouldSkip(f.Name) { - continue - } - w, err := zw.Create(f.Name) - if err != nil { - return err - } - rc, err := f.Open() - if err != nil { - return err - } - _, err = io.Copy(w, rc) - rc.Close() - if err != nil { - return err - } - } - return zw.Close() -} - -// shouldSkip reports whether a zip entry path contains a skipDirs segment. -func shouldSkip(path string) bool { - for _, seg := range strings.Split(filepath.ToSlash(path), "/") { - if skipDirs[seg] { - return true - } - } - return false -} - -// 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 - }) + return archive.CreateZip(dir) } diff --git a/internal/find/zip_test.go b/internal/find/zip_test.go index 7fc38f6..c68b44e 100644 --- a/internal/find/zip_test.go +++ b/internal/find/zip_test.go @@ -6,10 +6,12 @@ import ( "path/filepath" "strings" "testing" + + "github.com/supermodeltools/cli/internal/archive" ) func TestIsGitRepo_NotGit(t *testing.T) { - if isGitRepo(t.TempDir()) { + if archive.IsGitRepo(t.TempDir()) { t.Error("empty temp dir should not be a git repo") } } @@ -21,7 +23,7 @@ func TestWalkZip_IncludesFiles(t *testing.T) { } dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatalf("walkZip: %v", err) } entries := readZipEntries(t, dest) @@ -40,7 +42,7 @@ func TestWalkZip_SkipsHiddenFiles(t *testing.T) { } dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatal(err) } entries := readZipEntries(t, dest) @@ -63,7 +65,7 @@ func TestWalkZip_SkipsSkipDirs(t *testing.T) { } dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { + if err := archive.WalkZip(src, dest); err != nil { t.Fatal(err) } entries := readZipEntries(t, dest) diff --git a/internal/focus/zip.go b/internal/focus/zip.go index a38fb46..ee99e87 100644 --- a/internal/focus/zip.go +++ b/internal/focus/zip.go @@ -1,181 +1,13 @@ package focus import ( - "archive/zip" - "fmt" - "io" - "os" - "os/exec" - "github.com/supermodeltools/cli/internal/api" + "github.com/supermodeltools/cli/internal/archive" "github.com/supermodeltools/cli/internal/config" - "path/filepath" - "strings" ) -// skipDirs are directory names that should never be included in the archive. -var skipDirs = map[string]bool{ - ".git": true, - ".claude": true, - ".idea": true, - ".vscode": true, - ".cache": true, - ".turbo": true, - ".nx": true, - ".next": true, - ".nuxt": true, - ".terraform": true, - ".tox": true, - ".venv": true, - ".pnpm-store": true, - "__pycache__": true, - "__snapshots__": true, - "bower_components": true, - "build": true, - "coverage": true, - "dist": true, - "node_modules": true, - "out": true, - "target": true, - "vendor": true, - "venv": 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. -// In both cases, skipDirs entries are excluded. 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 { - if err := filterSkipDirs(dest); err != nil { - os.Remove(dest) - return "", fmt.Errorf("filter archive: %w", err) - } - 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() -} - -// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. -func filterSkipDirs(zipPath string) error { - data, err := os.ReadFile(zipPath) - if err != nil { - return err - } - - r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) - if err != nil { - return err - } - - out, err := os.Create(zipPath) - if err != nil { - return err - } - defer out.Close() - - zw := zip.NewWriter(out) - for _, f := range r.File { - if shouldSkip(f.Name) { - continue - } - w, err := zw.Create(f.Name) - if err != nil { - return err - } - rc, err := f.Open() - if err != nil { - return err - } - _, err = io.Copy(w, rc) - rc.Close() - if err != nil { - return err - } - } - return zw.Close() -} - -// shouldSkip reports whether a zip entry path contains a skipDirs segment. -func shouldSkip(path string) bool { - for _, seg := range strings.Split(filepath.ToSlash(path), "/") { - if skipDirs[seg] { - return true - } - } - return false -} - -// 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 - }) + return archive.CreateZip(dir) } func newAPIClient(cfg *config.Config) *api.Client { diff --git a/internal/mcp/zip.go b/internal/mcp/zip.go index 97a3b67..f33e80f 100644 --- a/internal/mcp/zip.go +++ b/internal/mcp/zip.go @@ -1,176 +1,7 @@ package mcp -import ( - "archive/zip" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" -) +import "github.com/supermodeltools/cli/internal/archive" -// skipDirs are directory names that should never be included in the archive. -var skipDirs = map[string]bool{ - ".git": true, - ".claude": true, - ".idea": true, - ".vscode": true, - ".cache": true, - ".turbo": true, - ".nx": true, - ".next": true, - ".nuxt": true, - ".terraform": true, - ".tox": true, - ".venv": true, - ".pnpm-store": true, - "__pycache__": true, - "__snapshots__": true, - "bower_components": true, - "build": true, - "coverage": true, - "dist": true, - "node_modules": true, - "out": true, - "target": true, - "vendor": true, - "venv": 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. -// In both cases, skipDirs entries are excluded. 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 { - if err := filterSkipDirs(dest); err != nil { - os.Remove(dest) - return "", fmt.Errorf("filter archive: %w", err) - } - 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() -} - -// filterSkipDirs removes entries from a ZIP whose path contains a skipDirs segment. -func filterSkipDirs(zipPath string) error { - data, err := os.ReadFile(zipPath) - if err != nil { - return err - } - - r, err := zip.NewReader(strings.NewReader(string(data)), int64(len(data))) - if err != nil { - return err - } - - out, err := os.Create(zipPath) - if err != nil { - return err - } - defer out.Close() - - zw := zip.NewWriter(out) - for _, f := range r.File { - if shouldSkip(f.Name) { - continue - } - w, err := zw.Create(f.Name) - if err != nil { - return err - } - rc, err := f.Open() - if err != nil { - return err - } - _, err = io.Copy(w, rc) - rc.Close() - if err != nil { - return err - } - } - return zw.Close() -} - -// shouldSkip reports whether a zip entry path contains a skipDirs segment. -func shouldSkip(path string) bool { - for _, seg := range strings.Split(filepath.ToSlash(path), "/") { - if skipDirs[seg] { - return true - } - } - return false -} - -// 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 - }) + return archive.CreateZip(dir) } From b9e6c677321d9fc744e8a54783ecb89ad04eb986 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 3 Apr 2026 15:39:58 -0400 Subject: [PATCH 3/4] =?UTF-8?q?lint:=20add=20rule=203=20=E2=80=94=20no=20d?= =?UTF-8?q?uplicate=20skipDirs=20outside=20internal/archive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scans all Go source files in internal/ for skipDirs variable declarations. Fails if any are found outside internal/archive/archive.go. Prevents the 8-way drift that already happened once. Also adds internal/archive to the sharedKernel allowlist. --- scripts/check-architecture/main.go | 52 +++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/scripts/check-architecture/main.go b/scripts/check-architecture/main.go index 55741e6..8e1b0d0 100644 --- a/scripts/check-architecture/main.go +++ b/scripts/check-architecture/main.go @@ -5,6 +5,7 @@ // // 1. Slice packages must not import other slice packages. // 2. Slice packages may only import the shared kernel or external dependencies. +// 3. SkipDirs must only be declared in internal/archive (no duplicate definitions in slices). // // A "slice" is any package under internal/ that is NOT listed in sharedKernel. // @@ -38,11 +39,12 @@ const module = "github.com/supermodeltools/cli" // sharedKernel lists internal packages that slices are permitted to import. // When adding a new cross-cutting infrastructure package, add it here. var sharedKernel = map[string]bool{ - "internal/api": true, - "internal/build": true, - "internal/cache": true, - "internal/config": true, - "internal/ui": true, + "internal/api": true, + "internal/archive": true, + "internal/build": true, + "internal/cache": true, + "internal/config": true, + "internal/ui": true, // pkg/ is a public SDK, not subject to slice rules. } @@ -141,6 +143,46 @@ func main() { } fmt.Println("✓ Architecture check passed — no cross-slice imports found.") + + // Rule 3: Slices must not declare their own SkipDirs — use internal/archive. + if dupes := checkDuplicateSkipDirs(); len(dupes) > 0 { + fmt.Fprintln(os.Stderr, "\n✗ Duplicate skipDirs declarations found:") + for _, d := range dupes { + fmt.Fprintf(os.Stderr, " %s\n", d) + } + fmt.Fprintln(os.Stderr, "\nSkipDirs must only be declared in internal/archive/archive.go.") + fmt.Fprintln(os.Stderr, "Slice zip.go files should import internal/archive instead.") + os.Exit(1) + } + fmt.Println("✓ No duplicate skipDirs — single source of truth in internal/archive.") +} + +// checkDuplicateSkipDirs scans Go source files for skipDirs/SkipDirs variable +// declarations outside of internal/archive. Returns file paths of violators. +func checkDuplicateSkipDirs() []string { + var dupes []string + _ = filepath.Walk("internal", func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + // Skip the canonical location. + if strings.HasPrefix(filepath.ToSlash(path), "internal/archive/") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + content := string(data) + if strings.Contains(content, "var skipDirs") || strings.Contains(content, "var SkipDirs") { + dupes = append(dupes, path) + } + return nil + }) + return dupes } // buildPackageMap maps each node ID to an "internal/X" package path. From 397a8e5e19452955abf6e177948a1392d5dff331 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 3 Apr 2026 15:53:44 -0400 Subject: [PATCH 4/4] fix(lint): suppress gosec G110 on trusted zip, remove unused copyFile --- internal/archive/archive.go | 2 +- internal/factory/zip.go | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 6414a1c..6879791 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -116,7 +116,7 @@ func FilterSkipDirs(zipPath string) error { if err != nil { return err } - _, err = io.Copy(w, rc) + _, err = io.Copy(w, rc) //nolint:gosec // source is our own git archive output, not untrusted rc.Close() if err != nil { return err diff --git a/internal/factory/zip.go b/internal/factory/zip.go index 4b4ca9d..d60eab0 100644 --- a/internal/factory/zip.go +++ b/internal/factory/zip.go @@ -1,7 +1,6 @@ package factory import ( - "io" "os" "os/exec" "path/filepath" @@ -50,12 +49,3 @@ func isWorktreeClean(dir string) bool { return err == nil && strings.TrimSpace(string(out)) == "" } -func copyFile(path string, w io.Writer) error { - f, err := os.Open(path) //nolint:gosec // path is from filepath.Walk; symlinks excluded by caller - if err != nil { - return err - } - _, err = io.Copy(w, f) - f.Close() - return err -}