mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-15 12:23:42 +00:00
feat: port the path patterns of @actions/glob (#1342)
Built-in actions that select files need to match what the JavaScript actions match. This ports @actions/glob 0.6.1 and the minimatch 3 subset it uses, as bundled in `actions/upload-artifact` v7. It only answers questions about paths; walking the files stays with the caller. Only malformed bracket expressions and some non-ASCII case folding on Windows can match differently. Co-authored-by: silverwind <[email protected]> Reviewed-on: https://gitea.com/gitea/runner/pulls/1342 Reviewed-by: silverwind <[email protected]> Co-authored-by: bircni <[email protected]>
This commit is contained in:
1 parent
bb6d793cb9
commit
b88d111cc5
5 files changed
+1542
No files matched your search
@@ -0,0 +1,239 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package glob ports the pattern semantics of the JavaScript @actions/glob 0.6.1 package, as
|
||||
// bundled in actions/upload-artifact, without walking a filesystem. Malformed bracket expressions
|
||||
// and some non-ASCII case folding on Windows can match differently.
|
||||
package glob
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Workdir string // absolute; relative patterns resolve against it
|
||||
Home func() (string, error) // replaces ~, called once and only when a pattern uses it
|
||||
Windows bool // Windows path flavour
|
||||
ImplicitDescendants bool
|
||||
}
|
||||
|
||||
type MatchKind int
|
||||
|
||||
const (
|
||||
MatchNone MatchKind = 0
|
||||
MatchDirectory MatchKind = 1
|
||||
MatchFile MatchKind = 2
|
||||
MatchAll = MatchDirectory | MatchFile
|
||||
)
|
||||
|
||||
type Globber struct {
|
||||
patterns []*pattern
|
||||
searchPaths []string
|
||||
}
|
||||
|
||||
// Parse parses newline separated patterns like DefaultGlobber.create.
|
||||
func Parse(patterns string, o Options) (*Globber, error) {
|
||||
w := flavour{o.Windows}
|
||||
env := parseEnv{w: w, cwd: w.normalizeSeparators(o.Workdir)}
|
||||
if o.Home != nil {
|
||||
env.home = sync.OnceValues(o.Home)
|
||||
}
|
||||
if env.cwd == "" || !w.hasAbsoluteRoot(env.cwd) {
|
||||
return nil, fmt.Errorf("workdir '%s' is not an absolute path", o.Workdir)
|
||||
}
|
||||
if o.Windows {
|
||||
patterns = strings.ReplaceAll(patterns, "\r\n", "\n")
|
||||
patterns = strings.ReplaceAll(patterns, "\r", "\n")
|
||||
}
|
||||
|
||||
var parsed []*pattern
|
||||
for line := range strings.SplitSeq(patterns, "\n") {
|
||||
line = jsTrim(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
p, err := newPattern(line, false, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed = append(parsed, p)
|
||||
}
|
||||
|
||||
g := &Globber{searchPaths: searchPaths(parsed, w)}
|
||||
for _, p := range parsed {
|
||||
g.patterns = append(g.patterns, p)
|
||||
if o.ImplicitDescendants && (p.trailingSeparator || p.segments[len(p.segments)-1] != "**") {
|
||||
s := jsTrim(w.joinSegments(append(append([]string(nil), p.segments...), "**")))
|
||||
if p.negate {
|
||||
s = "!" + s
|
||||
}
|
||||
implicit, err := newPattern(s, true, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.patterns = append(g.patterns, implicit)
|
||||
}
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// SearchPaths returns the literal prefix of each include pattern, without duplicates and
|
||||
// paths nested under another search path.
|
||||
func (g *Globber) SearchPaths() []string {
|
||||
return append([]string(nil), g.searchPaths...)
|
||||
}
|
||||
|
||||
// Match ORs in each include pattern and masks out each exclude pattern, in order.
|
||||
func (g *Globber) Match(p string) MatchKind {
|
||||
result := MatchNone
|
||||
for _, pat := range g.patterns {
|
||||
if pat.negate {
|
||||
result &^= pat.match(p)
|
||||
} else {
|
||||
result |= pat.match(p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// PartialMatch reports whether an include pattern may match descendants of p.
|
||||
func (g *Globber) PartialMatch(p string) bool {
|
||||
for _, pat := range g.patterns {
|
||||
if !pat.negate && pat.partialMatch(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func searchPaths(patterns []*pattern, w flavour) []string {
|
||||
key := func(p *pattern) string {
|
||||
if w.windows {
|
||||
return strings.ToUpper(p.searchPath)
|
||||
}
|
||||
return p.searchPath
|
||||
}
|
||||
const candidate, included = 1, 2
|
||||
seen := map[string]int{}
|
||||
for _, p := range patterns {
|
||||
if !p.negate {
|
||||
seen[key(p)] = candidate
|
||||
}
|
||||
}
|
||||
var result []string
|
||||
for _, p := range patterns {
|
||||
if p.negate {
|
||||
continue
|
||||
}
|
||||
k := key(p)
|
||||
if seen[k] == included {
|
||||
continue
|
||||
}
|
||||
foundAncestor := false
|
||||
for temp, parent := k, w.dirname(k); parent != temp; temp, parent = parent, w.dirname(parent) {
|
||||
if seen[parent] != 0 {
|
||||
foundAncestor = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundAncestor {
|
||||
result = append(result, p.searchPath)
|
||||
seen[k] = included
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LeastCommonAncestor ports getMultiPathLCA from upload-artifact's search.ts, which becomes the
|
||||
// artifact root when there are several search paths. Segments compare case-sensitively there,
|
||||
// even on Windows.
|
||||
func LeastCommonAncestor(paths []string, windows bool) string {
|
||||
switch len(paths) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return paths[0]
|
||||
}
|
||||
w := flavour{windows}
|
||||
sep := w.sep()
|
||||
split := make([][]string, len(paths))
|
||||
smallest := -1
|
||||
for i, p := range paths {
|
||||
if w.windows {
|
||||
p = w.normalizeSeparators(p)
|
||||
} else {
|
||||
p = path.Clean(p)
|
||||
}
|
||||
split[i] = strings.Split(p, sep)
|
||||
if smallest < 0 || len(split[i]) < smallest {
|
||||
smallest = len(split[i])
|
||||
}
|
||||
}
|
||||
|
||||
var common []string
|
||||
if strings.HasPrefix(paths[0], sep) {
|
||||
common = append(common, sep)
|
||||
}
|
||||
for i := 0; i < smallest; i++ {
|
||||
same := true
|
||||
for _, s := range split[1:] {
|
||||
if s[i] != split[0][i] {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !same {
|
||||
break
|
||||
}
|
||||
common = append(common, split[0][i])
|
||||
}
|
||||
return w.nodeJoin(common)
|
||||
}
|
||||
|
||||
// nodeJoin ports Node's path.join for the already normalized segments LeastCommonAncestor produces.
|
||||
func (w flavour) nodeJoin(parts []string) string {
|
||||
var nonEmpty []string
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
nonEmpty = append(nonEmpty, p)
|
||||
}
|
||||
}
|
||||
if len(nonEmpty) == 0 {
|
||||
return "."
|
||||
}
|
||||
joined := strings.Join(nonEmpty, w.sep())
|
||||
if !w.windows {
|
||||
return path.Clean(joined)
|
||||
}
|
||||
first := nonEmpty[0]
|
||||
needsReplace, slashCount := true, 0
|
||||
if w.isSep(first[0]) {
|
||||
slashCount++
|
||||
if len(first) > 1 && w.isSep(first[1]) {
|
||||
slashCount++
|
||||
if len(first) > 2 {
|
||||
if w.isSep(first[2]) {
|
||||
slashCount++
|
||||
} else {
|
||||
needsReplace = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if needsReplace {
|
||||
for slashCount < len(joined) && w.isSep(joined[slashCount]) {
|
||||
slashCount++
|
||||
}
|
||||
if slashCount >= 2 {
|
||||
joined = `\` + joined[slashCount:]
|
||||
}
|
||||
}
|
||||
// path.win32.normalize turns a bare drive into the drive relative "C:.".
|
||||
if reDriveOnly.MatchString(joined) {
|
||||
return joined + "."
|
||||
}
|
||||
return joined
|
||||
}
|
||||
Reference in new issue
Block a user