Respect gitignore when installing extensions
- commit
- 30cff8843da6ee942e18b4ab245db3d6ca2ead11
- author
- patriceckhart · · 2026-06-07 08:25 UTC
- parent
- 10fde8f
- signature
- unsigned
2 files · +170 -0
M packages/agent/extcmd.go +115 -0
| @@ -323,7 +323,14 @@ func dashIfEmpty(s string) string { | |||
| 323 | 323 | ||
| 324 | 324 | // copyDir does a recursive copy of src to dst preserving file mode | |
| 325 | 325 | // bits. Used by `zot ext install <local-path>`. | |
| 326 | + | // | |
| 327 | + | // Entries matched by the source's root .gitignore are skipped, and | |
| 328 | + | // .git itself is always skipped. This keeps non-portable, regeneratable | |
| 329 | + | // directories (e.g. .venv with hardcoded rpaths, node_modules, target/) | |
| 330 | + | // out of the installed copy so the extension stays functional at its new | |
| 331 | + | // location. | |
| 326 | 332 | func copyDir(src, dst string) error { | |
| 333 | + | ig := loadGitignore(src) | |
| 327 | 334 | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | |
| 328 | 335 | if err != nil { | |
| 329 | 336 | return err | |
| @@ -332,6 +339,18 @@ func copyDir(src, dst string) error { | |||
| 332 | 339 | if err != nil { | |
| 333 | 340 | return err | |
| 334 | 341 | } | |
| 342 | + | if rel != "." { | |
| 343 | + | name := filepath.Base(rel) | |
| 344 | + | if info.IsDir() && name == ".git" { | |
| 345 | + | return filepath.SkipDir | |
| 346 | + | } | |
| 347 | + | if ig.match(filepath.ToSlash(rel), info.IsDir()) { | |
| 348 | + | if info.IsDir() { | |
| 349 | + | return filepath.SkipDir | |
| 350 | + | } | |
| 351 | + | return nil | |
| 352 | + | } | |
| 353 | + | } | |
| 335 | 354 | target := filepath.Join(dst, rel) | |
| 336 | 355 | if info.IsDir() { | |
| 337 | 356 | return os.MkdirAll(target, info.Mode()) | |
| @@ -350,3 +369,99 @@ func copyDir(src, dst string) error { | |||
| 350 | 369 | return err | |
| 351 | 370 | }) | |
| 352 | 371 | } | |
| 372 | + | ||
| 373 | + | // gitignore is a minimal .gitignore matcher. It supports the common | |
| 374 | + | // patterns used in real extension repos: blank lines, comments (#), | |
| 375 | + | // negation (!), directory-only patterns (trailing /), anchored | |
| 376 | + | // patterns (leading /), and the * / ? / [..] wildcards via | |
| 377 | + | // filepath.Match. It intentionally does not implement ** globstar or | |
| 378 | + | // nested per-directory .gitignore files; the goal is to drop obvious | |
| 379 | + | // non-portable directories, not to be a faithful git reimplementation. | |
| 380 | + | type gitignore struct { | |
| 381 | + | rules []gitignoreRule | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | type gitignoreRule struct { | |
| 385 | + | pattern string | |
| 386 | + | negate bool | |
| 387 | + | dirOnly bool | |
| 388 | + | anchored bool | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func loadGitignore(root string) *gitignore { | |
| 392 | + | data, err := os.ReadFile(filepath.Join(root, ".gitignore")) | |
| 393 | + | if err != nil { | |
| 394 | + | return &gitignore{} | |
| 395 | + | } | |
| 396 | + | return loadGitignoreFromString(string(data)) | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func loadGitignoreFromString(data string) *gitignore { | |
| 400 | + | g := &gitignore{} | |
| 401 | + | for _, line := range strings.Split(data, "\n") { | |
| 402 | + | line = strings.TrimRight(line, "\r") | |
| 403 | + | trimmed := strings.TrimSpace(line) | |
| 404 | + | if trimmed == "" || strings.HasPrefix(trimmed, "#") { | |
| 405 | + | continue | |
| 406 | + | } | |
| 407 | + | r := gitignoreRule{pattern: trimmed} | |
| 408 | + | if strings.HasPrefix(r.pattern, "!") { | |
| 409 | + | r.negate = true | |
| 410 | + | r.pattern = r.pattern[1:] | |
| 411 | + | } | |
| 412 | + | if strings.HasSuffix(r.pattern, "/") { | |
| 413 | + | r.dirOnly = true | |
| 414 | + | r.pattern = strings.TrimSuffix(r.pattern, "/") | |
| 415 | + | } | |
| 416 | + | if strings.HasPrefix(r.pattern, "/") { | |
| 417 | + | r.anchored = true | |
| 418 | + | r.pattern = strings.TrimPrefix(r.pattern, "/") | |
| 419 | + | } | |
| 420 | + | if r.pattern == "" { | |
| 421 | + | continue | |
| 422 | + | } | |
| 423 | + | g.rules = append(g.rules, r) | |
| 424 | + | } | |
| 425 | + | return g | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // match reports whether the slash-separated relative path should be | |
| 429 | + | // ignored. Later rules win, so a trailing negation can re-include a | |
| 430 | + | // previously ignored path. | |
| 431 | + | func (g *gitignore) match(rel string, isDir bool) bool { | |
| 432 | + | ignored := false | |
| 433 | + | for _, r := range g.rules { | |
| 434 | + | if r.dirOnly && !isDir { | |
| 435 | + | continue | |
| 436 | + | } | |
| 437 | + | if r.matchPath(rel) { | |
| 438 | + | ignored = !r.negate | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | return ignored | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | func (r gitignoreRule) matchPath(rel string) bool { | |
| 445 | + | if r.anchored || strings.Contains(r.pattern, "/") { | |
| 446 | + | if ok, _ := filepath.Match(r.pattern, rel); ok { | |
| 447 | + | return true | |
| 448 | + | } | |
| 449 | + | // Anchored directory pattern also matches everything beneath it. | |
| 450 | + | return strings.HasPrefix(rel, r.pattern+"/") | |
| 451 | + | } | |
| 452 | + | // Unanchored: match the basename of any path component. | |
| 453 | + | base := rel | |
| 454 | + | if i := strings.LastIndex(rel, "/"); i >= 0 { | |
| 455 | + | base = rel[i+1:] | |
| 456 | + | } | |
| 457 | + | if ok, _ := filepath.Match(r.pattern, base); ok { | |
| 458 | + | return true | |
| 459 | + | } | |
| 460 | + | // Match a directory component anywhere in the path. | |
| 461 | + | for _, part := range strings.Split(rel, "/") { | |
| 462 | + | if ok, _ := filepath.Match(r.pattern, part); ok { | |
| 463 | + | return true | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | return false | |
| 467 | + | } | |
| @@ -323,7 +323,14 @@ func dashIfEmpty(s string) string { | |||||
| 323 | 323 | ||||
| 324 | // copyDir does a recursive copy of src to dst preserving file mode | 324 | // copyDir does a recursive copy of src to dst preserving file mode | ||
| 325 | // bits. Used by `zot ext install <local-path>`. | 325 | // bits. Used by `zot ext install <local-path>`. | ||
| 326 | + | // | |||
| 327 | + | // Entries matched by the source's root .gitignore are skipped, and | |||
| 328 | + | // .git itself is always skipped. This keeps non-portable, regeneratable | |||
| 329 | + | // directories (e.g. .venv with hardcoded rpaths, node_modules, target/) | |||
| 330 | + | // out of the installed copy so the extension stays functional at its new | |||
| 331 | + | // location. | |||
| 326 | func copyDir(src, dst string) error { | 332 | func copyDir(src, dst string) error { | ||
| 333 | + | ig := loadGitignore(src) | |||
| 327 | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | 334 | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | ||
| 328 | if err != nil { | 335 | if err != nil { | ||
| 329 | return err | 336 | return err | ||
| @@ -332,6 +339,18 @@ func copyDir(src, dst string) error { | |||||
| 332 | if err != nil { | 339 | if err != nil { | ||
| 333 | return err | 340 | return err | ||
| 334 | } | 341 | } | ||
| 342 | + | if rel != "." { | |||
| 343 | + | name := filepath.Base(rel) | |||
| 344 | + | if info.IsDir() && name == ".git" { | |||
| 345 | + | return filepath.SkipDir | |||
| 346 | + | } | |||
| 347 | + | if ig.match(filepath.ToSlash(rel), info.IsDir()) { | |||
| 348 | + | if info.IsDir() { | |||
| 349 | + | return filepath.SkipDir | |||
| 350 | + | } | |||
| 351 | + | return nil | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 335 | target := filepath.Join(dst, rel) | 354 | target := filepath.Join(dst, rel) | ||
| 336 | if info.IsDir() { | 355 | if info.IsDir() { | ||
| 337 | return os.MkdirAll(target, info.Mode()) | 356 | return os.MkdirAll(target, info.Mode()) | ||
| @@ -350,3 +369,99 @@ func copyDir(src, dst string) error { | |||||
| 350 | return err | 369 | return err | ||
| 351 | }) | 370 | }) | ||
| 352 | } | 371 | } | ||
| 372 | + | ||||
| 373 | + | // gitignore is a minimal .gitignore matcher. It supports the common | |||
| 374 | + | // patterns used in real extension repos: blank lines, comments (#), | |||
| 375 | + | // negation (!), directory-only patterns (trailing /), anchored | |||
| 376 | + | // patterns (leading /), and the * / ? / [..] wildcards via | |||
| 377 | + | // filepath.Match. It intentionally does not implement ** globstar or | |||
| 378 | + | // nested per-directory .gitignore files; the goal is to drop obvious | |||
| 379 | + | // non-portable directories, not to be a faithful git reimplementation. | |||
| 380 | + | type gitignore struct { | |||
| 381 | + | rules []gitignoreRule | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | type gitignoreRule struct { | |||
| 385 | + | pattern string | |||
| 386 | + | negate bool | |||
| 387 | + | dirOnly bool | |||
| 388 | + | anchored bool | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func loadGitignore(root string) *gitignore { | |||
| 392 | + | data, err := os.ReadFile(filepath.Join(root, ".gitignore")) | |||
| 393 | + | if err != nil { | |||
| 394 | + | return &gitignore{} | |||
| 395 | + | } | |||
| 396 | + | return loadGitignoreFromString(string(data)) | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func loadGitignoreFromString(data string) *gitignore { | |||
| 400 | + | g := &gitignore{} | |||
| 401 | + | for _, line := range strings.Split(data, "\n") { | |||
| 402 | + | line = strings.TrimRight(line, "\r") | |||
| 403 | + | trimmed := strings.TrimSpace(line) | |||
| 404 | + | if trimmed == "" || strings.HasPrefix(trimmed, "#") { | |||
| 405 | + | continue | |||
| 406 | + | } | |||
| 407 | + | r := gitignoreRule{pattern: trimmed} | |||
| 408 | + | if strings.HasPrefix(r.pattern, "!") { | |||
| 409 | + | r.negate = true | |||
| 410 | + | r.pattern = r.pattern[1:] | |||
| 411 | + | } | |||
| 412 | + | if strings.HasSuffix(r.pattern, "/") { | |||
| 413 | + | r.dirOnly = true | |||
| 414 | + | r.pattern = strings.TrimSuffix(r.pattern, "/") | |||
| 415 | + | } | |||
| 416 | + | if strings.HasPrefix(r.pattern, "/") { | |||
| 417 | + | r.anchored = true | |||
| 418 | + | r.pattern = strings.TrimPrefix(r.pattern, "/") | |||
| 419 | + | } | |||
| 420 | + | if r.pattern == "" { | |||
| 421 | + | continue | |||
| 422 | + | } | |||
| 423 | + | g.rules = append(g.rules, r) | |||
| 424 | + | } | |||
| 425 | + | return g | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | // match reports whether the slash-separated relative path should be | |||
| 429 | + | // ignored. Later rules win, so a trailing negation can re-include a | |||
| 430 | + | // previously ignored path. | |||
| 431 | + | func (g *gitignore) match(rel string, isDir bool) bool { | |||
| 432 | + | ignored := false | |||
| 433 | + | for _, r := range g.rules { | |||
| 434 | + | if r.dirOnly && !isDir { | |||
| 435 | + | continue | |||
| 436 | + | } | |||
| 437 | + | if r.matchPath(rel) { | |||
| 438 | + | ignored = !r.negate | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | return ignored | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | func (r gitignoreRule) matchPath(rel string) bool { | |||
| 445 | + | if r.anchored || strings.Contains(r.pattern, "/") { | |||
| 446 | + | if ok, _ := filepath.Match(r.pattern, rel); ok { | |||
| 447 | + | return true | |||
| 448 | + | } | |||
| 449 | + | // Anchored directory pattern also matches everything beneath it. | |||
| 450 | + | return strings.HasPrefix(rel, r.pattern+"/") | |||
| 451 | + | } | |||
| 452 | + | // Unanchored: match the basename of any path component. | |||
| 453 | + | base := rel | |||
| 454 | + | if i := strings.LastIndex(rel, "/"); i >= 0 { | |||
| 455 | + | base = rel[i+1:] | |||
| 456 | + | } | |||
| 457 | + | if ok, _ := filepath.Match(r.pattern, base); ok { | |||
| 458 | + | return true | |||
| 459 | + | } | |||
| 460 | + | // Match a directory component anywhere in the path. | |||
| 461 | + | for _, part := range strings.Split(rel, "/") { | |||
| 462 | + | if ok, _ := filepath.Match(r.pattern, part); ok { | |||
| 463 | + | return true | |||
| 464 | + | } | |||
| 465 | + | } | |||
| 466 | + | return false | |||
| 467 | + | } | |||
M packages/agent/extcmd_test.go +55 -0
| @@ -69,3 +69,58 @@ func TestExtInstallNamedDir(t *testing.T) { | |||
| 69 | 69 | t.Fatalf("expected installed extension: %v", err) | |
| 70 | 70 | } | |
| 71 | 71 | } | |
| 72 | + | ||
| 73 | + | // TestCopyDirRespectsGitignore verifies that non-portable directories | |
| 74 | + | // listed in the source .gitignore (e.g. .venv, node_modules) are not | |
| 75 | + | // copied during install, while tracked files are. | |
| 76 | + | func TestCopyDirRespectsGitignore(t *testing.T) { | |
| 77 | + | src := t.TempDir() | |
| 78 | + | dst := filepath.Join(t.TempDir(), "out") | |
| 79 | + | ||
| 80 | + | mustWrite := func(rel, content string) { | |
| 81 | + | p := filepath.Join(src, filepath.FromSlash(rel)) | |
| 82 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | if err := os.WriteFile(p, []byte(content), 0o644); err != nil { | |
| 86 | + | t.Fatal(err) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | mustWrite("extension.json", `{"name":"x"}`) | |
| 91 | + | mustWrite("main.py", "print('hi')") | |
| 92 | + | mustWrite(".gitignore", ".venv/\nnode_modules/\n*.log\n") | |
| 93 | + | mustWrite(".venv/bin/python", "binary") | |
| 94 | + | mustWrite("node_modules/pkg/index.js", "module") | |
| 95 | + | mustWrite("debug.log", "noise") | |
| 96 | + | mustWrite("src/app.py", "code") | |
| 97 | + | mustWrite(".git/config", "gitdir") | |
| 98 | + | ||
| 99 | + | if err := copyDir(src, dst); err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | wantPresent := []string{"extension.json", "main.py", "src/app.py", ".gitignore"} | |
| 104 | + | for _, rel := range wantPresent { | |
| 105 | + | if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err != nil { | |
| 106 | + | t.Fatalf("expected %s to be copied: %v", rel, err) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | wantAbsent := []string{".venv", "node_modules", "debug.log", ".git"} | |
| 111 | + | for _, rel := range wantAbsent { | |
| 112 | + | if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err == nil { | |
| 113 | + | t.Fatalf("expected %s to be skipped, but it was copied", rel) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestGitignoreNegation(t *testing.T) { | |
| 119 | + | g := loadGitignoreFromString("build/\n!build/keep.txt\n") | |
| 120 | + | if !g.match("build", true) { | |
| 121 | + | t.Fatal("expected build/ dir to be ignored") | |
| 122 | + | } | |
| 123 | + | if g.match("build/keep.txt", false) { | |
| 124 | + | t.Fatal("expected build/keep.txt to be re-included by negation") | |
| 125 | + | } | |
| 126 | + | } | |
| @@ -69,3 +69,58 @@ func TestExtInstallNamedDir(t *testing.T) { | |||||
| 69 | t.Fatalf("expected installed extension: %v", err) | 69 | t.Fatalf("expected installed extension: %v", err) | ||
| 70 | } | 70 | } | ||
| 71 | } | 71 | } | ||
| 72 | + | ||||
| 73 | + | // TestCopyDirRespectsGitignore verifies that non-portable directories | |||
| 74 | + | // listed in the source .gitignore (e.g. .venv, node_modules) are not | |||
| 75 | + | // copied during install, while tracked files are. | |||
| 76 | + | func TestCopyDirRespectsGitignore(t *testing.T) { | |||
| 77 | + | src := t.TempDir() | |||
| 78 | + | dst := filepath.Join(t.TempDir(), "out") | |||
| 79 | + | ||||
| 80 | + | mustWrite := func(rel, content string) { | |||
| 81 | + | p := filepath.Join(src, filepath.FromSlash(rel)) | |||
| 82 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | if err := os.WriteFile(p, []byte(content), 0o644); err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | mustWrite("extension.json", `{"name":"x"}`) | |||
| 91 | + | mustWrite("main.py", "print('hi')") | |||
| 92 | + | mustWrite(".gitignore", ".venv/\nnode_modules/\n*.log\n") | |||
| 93 | + | mustWrite(".venv/bin/python", "binary") | |||
| 94 | + | mustWrite("node_modules/pkg/index.js", "module") | |||
| 95 | + | mustWrite("debug.log", "noise") | |||
| 96 | + | mustWrite("src/app.py", "code") | |||
| 97 | + | mustWrite(".git/config", "gitdir") | |||
| 98 | + | ||||
| 99 | + | if err := copyDir(src, dst); err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | wantPresent := []string{"extension.json", "main.py", "src/app.py", ".gitignore"} | |||
| 104 | + | for _, rel := range wantPresent { | |||
| 105 | + | if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err != nil { | |||
| 106 | + | t.Fatalf("expected %s to be copied: %v", rel, err) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | wantAbsent := []string{".venv", "node_modules", "debug.log", ".git"} | |||
| 111 | + | for _, rel := range wantAbsent { | |||
| 112 | + | if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err == nil { | |||
| 113 | + | t.Fatalf("expected %s to be skipped, but it was copied", rel) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestGitignoreNegation(t *testing.T) { | |||
| 119 | + | g := loadGitignoreFromString("build/\n!build/keep.txt\n") | |||
| 120 | + | if !g.match("build", true) { | |||
| 121 | + | t.Fatal("expected build/ dir to be ignored") | |||
| 122 | + | } | |||
| 123 | + | if g.match("build/keep.txt", false) { | |||
| 124 | + | t.Fatal("expected build/keep.txt to be re-included by negation") | |||
| 125 | + | } | |||
| 126 | + | } | |||