diff --git a/internal/analyze/zip.go b/internal/analyze/zip.go index 6a6cc67..ba7a223 100644 --- a/internal/analyze/zip.go +++ b/internal/analyze/zip.go @@ -1,111 +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, - "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 - }) + 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 07066de..f0dbf18 100644 --- a/internal/archdocs/zip.go +++ b/internal/archdocs/zip.go @@ -1,111 +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, - "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 - }) + return archive.CreateZip(dir) } diff --git a/internal/archive/archive.go b/internal/archive/archive.go new file mode 100644 index 0000000..6879791 --- /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) //nolint:gosec // source is our own git archive output, not untrusted + 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 4769966..69b0300 100644 --- a/internal/blastradius/zip.go +++ b/internal/blastradius/zip.go @@ -1,111 +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, - "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 - }) + return archive.CreateZip(dir) } diff --git a/internal/deadcode/zip.go b/internal/deadcode/zip.go index 06c82a7..3355e16 100644 --- a/internal/deadcode/zip.go +++ b/internal/deadcode/zip.go @@ -1,111 +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, - "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 - }) + return archive.CreateZip(dir) } diff --git a/internal/factory/zip.go b/internal/factory/zip.go index 8e49ef3..d60eab0 100644 --- a/internal/factory/zip.go +++ b/internal/factory/zip.go @@ -1,38 +1,19 @@ 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, - "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, -} + "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. +// 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 { @@ -42,12 +23,16 @@ func CreateZip(dir string) (string, error) { f.Close() if isGitRepo(dir) && isWorktreeClean(dir) { - if err := gitArchive(dir, dest); err == nil { + if err := archive.GitArchive(dir, dest); err == nil { + if err := archive.FilterSkipDirs(dest); err != nil { + _ = os.Remove(dest) + return "", err + } return dest, nil } } - if err := walkZip(dir, dest); err != nil { + if err := archive.WalkZip(dir, dest); err != nil { _ = os.Remove(dest) return "", err } @@ -59,67 +44,8 @@ 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() -} - -// 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 - if err != nil { - return err - } - _, err = io.Copy(w, f) - f.Close() - 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 0225c57..871867f 100644 --- a/internal/find/zip.go +++ b/internal/find/zip.go @@ -1,88 +1,7 @@ package find -// zip.go duplicates archive helpers to preserve vertical slice isolation. - -import ( - "archive/zip" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" -) - -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, -} +import "github.com/supermodeltools/cli/internal/archive" func createZip(dir string) (string, error) { - f, err := os.CreateTemp("", "supermodel-find-*.zip") - if err != nil { - return "", fmt.Errorf("create temp: %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() -} - -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) - 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 84907d2..ee99e87 100644 --- a/internal/focus/zip.go +++ b/internal/focus/zip.go @@ -1,94 +1,13 @@ 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/archive" "github.com/supermodeltools/cli/internal/config" ) -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, -} - func createZip(dir string) (string, error) { - f, err := os.CreateTemp("", "supermodel-focus-*.zip") - if err != nil { - return "", fmt.Errorf("create temp: %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() -} - -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) - 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 756cfc3..f33e80f 100644 --- a/internal/mcp/zip.go +++ b/internal/mcp/zip.go @@ -1,102 +1,7 @@ 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" - "io" - "os" - "os/exec" - "path/filepath" - "strings" -) - -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, -} +import "github.com/supermodeltools/cli/internal/archive" func createZip(dir string) (string, error) { - f, err := os.CreateTemp("", "supermodel-mcp-*.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() -} - -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/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.