Respect gitignore when installing extensions

This commit is contained in:
patriceckhart 2026-06-07 10:25:50 +02:00
parent 10fde8fd0e
commit 30cff8843d
2 changed files with 170 additions and 0 deletions

View file

@ -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 <local-path>`.
//
// 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
}

View file

@ -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")
}
}