detached 30cff88

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 {
323323
324324 // copyDir does a recursive copy of src to dst preserving file mode
325325 // 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.
326332 func copyDir(src, dst string) error {
333+ ig := loadGitignore(src)
327334 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
328335 if err != nil {
329336 return err
@@ -332,6 +339,18 @@ func copyDir(src, dst string) error {
332339 if err != nil {
333340 return err
334341 }
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+ }
335354 target := filepath.Join(dst, rel)
336355 if info.IsDir() {
337356 return os.MkdirAll(target, info.Mode())
@@ -350,3 +369,99 @@ func copyDir(src, dst string) error {
350369 return err
351370 })
352371 }
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 mode324 // 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 err336 return err
@@ -332,6 +339,18 @@ func copyDir(src, dst string) error {
332 if err != nil {339 if err != nil {
333 return err340 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 err369 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) {
6969 t.Fatalf("expected installed extension: %v", err)
7070 }
7171 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close