diff --git a/packages/agent/extcmd.go b/packages/agent/extcmd.go index 8e5503e..d286130 100644 --- a/packages/agent/extcmd.go +++ b/packages/agent/extcmd.go @@ -323,7 +323,14 @@ func dashIfEmpty(s string) string { // copyDir does a recursive copy of src to dst preserving file mode // bits. Used by `zot ext install `. +// +// Entries matched by the source's root .gitignore are skipped, and +// .git itself is always skipped. This keeps non-portable, regeneratable +// directories (e.g. .venv with hardcoded rpaths, node_modules, target/) +// out of the installed copy so the extension stays functional at its new +// location. func copyDir(src, dst string) error { + ig := loadGitignore(src) return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -332,6 +339,18 @@ func copyDir(src, dst string) error { if err != nil { return err } + if rel != "." { + name := filepath.Base(rel) + if info.IsDir() && name == ".git" { + return filepath.SkipDir + } + if ig.match(filepath.ToSlash(rel), info.IsDir()) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } target := filepath.Join(dst, rel) if info.IsDir() { return os.MkdirAll(target, info.Mode()) @@ -350,3 +369,99 @@ func copyDir(src, dst string) error { return err }) } + +// gitignore is a minimal .gitignore matcher. It supports the common +// patterns used in real extension repos: blank lines, comments (#), +// negation (!), directory-only patterns (trailing /), anchored +// patterns (leading /), and the * / ? / [..] wildcards via +// filepath.Match. It intentionally does not implement ** globstar or +// nested per-directory .gitignore files; the goal is to drop obvious +// non-portable directories, not to be a faithful git reimplementation. +type gitignore struct { + rules []gitignoreRule +} + +type gitignoreRule struct { + pattern string + negate bool + dirOnly bool + anchored bool +} + +func loadGitignore(root string) *gitignore { + data, err := os.ReadFile(filepath.Join(root, ".gitignore")) + if err != nil { + return &gitignore{} + } + return loadGitignoreFromString(string(data)) +} + +func loadGitignoreFromString(data string) *gitignore { + g := &gitignore{} + for _, line := range strings.Split(data, "\n") { + line = strings.TrimRight(line, "\r") + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + r := gitignoreRule{pattern: trimmed} + if strings.HasPrefix(r.pattern, "!") { + r.negate = true + r.pattern = r.pattern[1:] + } + if strings.HasSuffix(r.pattern, "/") { + r.dirOnly = true + r.pattern = strings.TrimSuffix(r.pattern, "/") + } + if strings.HasPrefix(r.pattern, "/") { + r.anchored = true + r.pattern = strings.TrimPrefix(r.pattern, "/") + } + if r.pattern == "" { + continue + } + g.rules = append(g.rules, r) + } + return g +} + +// match reports whether the slash-separated relative path should be +// ignored. Later rules win, so a trailing negation can re-include a +// previously ignored path. +func (g *gitignore) match(rel string, isDir bool) bool { + ignored := false + for _, r := range g.rules { + if r.dirOnly && !isDir { + continue + } + if r.matchPath(rel) { + ignored = !r.negate + } + } + return ignored +} + +func (r gitignoreRule) matchPath(rel string) bool { + if r.anchored || strings.Contains(r.pattern, "/") { + if ok, _ := filepath.Match(r.pattern, rel); ok { + return true + } + // Anchored directory pattern also matches everything beneath it. + return strings.HasPrefix(rel, r.pattern+"/") + } + // Unanchored: match the basename of any path component. + base := rel + if i := strings.LastIndex(rel, "/"); i >= 0 { + base = rel[i+1:] + } + if ok, _ := filepath.Match(r.pattern, base); ok { + return true + } + // Match a directory component anywhere in the path. + for _, part := range strings.Split(rel, "/") { + if ok, _ := filepath.Match(r.pattern, part); ok { + return true + } + } + return false +} diff --git a/packages/agent/extcmd_test.go b/packages/agent/extcmd_test.go index e0e3e5f..45c54dc 100644 --- a/packages/agent/extcmd_test.go +++ b/packages/agent/extcmd_test.go @@ -69,3 +69,58 @@ func TestExtInstallNamedDir(t *testing.T) { t.Fatalf("expected installed extension: %v", err) } } + +// TestCopyDirRespectsGitignore verifies that non-portable directories +// listed in the source .gitignore (e.g. .venv, node_modules) are not +// copied during install, while tracked files are. +func TestCopyDirRespectsGitignore(t *testing.T) { + src := t.TempDir() + dst := filepath.Join(t.TempDir(), "out") + + mustWrite := func(rel, content string) { + p := filepath.Join(src, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + mustWrite("extension.json", `{"name":"x"}`) + mustWrite("main.py", "print('hi')") + mustWrite(".gitignore", ".venv/\nnode_modules/\n*.log\n") + mustWrite(".venv/bin/python", "binary") + mustWrite("node_modules/pkg/index.js", "module") + mustWrite("debug.log", "noise") + mustWrite("src/app.py", "code") + mustWrite(".git/config", "gitdir") + + if err := copyDir(src, dst); err != nil { + t.Fatal(err) + } + + wantPresent := []string{"extension.json", "main.py", "src/app.py", ".gitignore"} + for _, rel := range wantPresent { + if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err != nil { + t.Fatalf("expected %s to be copied: %v", rel, err) + } + } + + wantAbsent := []string{".venv", "node_modules", "debug.log", ".git"} + for _, rel := range wantAbsent { + if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err == nil { + t.Fatalf("expected %s to be skipped, but it was copied", rel) + } + } +} + +func TestGitignoreNegation(t *testing.T) { + g := loadGitignoreFromString("build/\n!build/keep.txt\n") + if !g.match("build", true) { + t.Fatal("expected build/ dir to be ignored") + } + if g.match("build/keep.txt", false) { + t.Fatal("expected build/keep.txt to be re-included by negation") + } +}