mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
Respect gitignore when installing extensions
This commit is contained in:
parent
10fde8fd0e
commit
30cff8843d
2 changed files with 170 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue