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:
bircniandsilverwind authored and silverwind committed 2026-11-06 01:13:07 +00:00
1 parent bb6d793cb9
commit b88d111cc5
5 files changed
+1542

No files matched your search

+239
View File
@@ -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
}
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package glob
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
posix = Options{Workdir: "/work/cwd-with-[and]", Home: home("/home/home-with-[and]")}
windows = Options{Workdir: `C:\work\cwd-with-[and]`, Home: home(`C:\Users\home-with-[and]`), Windows: true}
)
func home(dir string) func() (string, error) {
return func() (string, error) { return dir, nil }
}
func implicit(o Options) Options {
o.ImplicitDescendants = true
return o
}
func TestHomeResolvedLazilyOnce(t *testing.T) {
calls := 0
o := Options{Workdir: "/w", Home: func() (string, error) { calls++; return "/h", nil }}
_, err := Parse("~/a\n! ~/b", o)
require.NoError(t, err)
_, err = Parse("/a", o)
require.NoError(t, err)
assert.Equal(t, 1, calls)
}
func TestMatch(t *testing.T) {
tests := []struct {
name string
patterns string
opts Options
want map[string]MatchKind
}{
{"negate markers toggle", "/a/*.txt\n!!/a/two.txt\n!/a/one.txt\n!!!/a/three.txt", posix, map[string]MatchKind{
"/a/two.txt": MatchAll, "/a/one.txt": MatchNone, "/a/three.txt": MatchNone, "/a/x.txt": MatchAll,
}},
{"home is escaped", "~/m*", posix, map[string]MatchKind{
"/home/home-with-[and]/match": MatchAll, "/home/home-with-[and]/not-match": MatchNone, "/home/home-with-a/match": MatchNone,
}},
{"workdir is escaped", "m*", posix, map[string]MatchKind{
"/work/cwd-with-[and]/match": MatchAll, "/work/cwd-with-[and]/not-match": MatchNone,
}},
{"workdir is escaped on windows", "m*\nC:n*\n" + `\work\cwd-with-[[]and]\o*`, windows, map[string]MatchKind{
`C:\work\cwd-with-[and]\match`: MatchAll, `C:\work\cwd-with-[and]\nope`: MatchAll, `C:\work\cwd-with-[and]\ok`: MatchAll, `C:\work\cwd-with-[and]\x`: MatchNone,
}},
{"globstar matches the directory before it", "/foo/bar/**", posix, map[string]MatchKind{
"/": MatchNone, "/foo": MatchNone, "/foo/bar": MatchAll, "/foo/bar/baz": MatchAll,
}},
{"globstar matches root", "/**", posix, map[string]MatchKind{"/": MatchAll}},
{"globstar matches root on windows", `C:\**`, windows, map[string]MatchKind{`C:\`: MatchAll}},
{"case sensitive on posix", "/Foo/**/Baz", posix, map[string]MatchKind{
"/Foo/Baz": MatchAll, "/Foo/bAZ": MatchNone, "/fOO/bar/Baz": MatchNone,
}},
{"case insensitive on windows", "C:/Foo/**/Baz", windows, map[string]MatchKind{
`C:\Foo\Baz`: MatchAll, `c:\Foo\bAZ`: MatchAll, "C:/fOO/bar/bAZ": MatchAll, `C:\Foo\Bar`: MatchNone,
}},
{"leading dot segment", ".\n./foo\n.bar", posix, map[string]MatchKind{
"/work/cwd-with-[and]": MatchAll, "/work/cwd-with-[and]/foo": MatchAll, "/work/cwd-with-[and]/.bar": MatchAll,
"/work/cwd-with-[and]/bar": MatchNone, "/work/cwd-with-[and]bar": MatchNone,
}},
{"leading tilde segment", "~\n~/foo\n~bar", posix, map[string]MatchKind{
"/home/home-with-[and]": MatchAll, "/home/home-with-[and]/foo": MatchAll, "/work/cwd-with-[and]/~bar": MatchAll,
"/home/home-with-[and]/bar": MatchNone, "/home/home-with-[and]bar": MatchNone,
}},
{"windows relative roots", "C:\nC:foo\nD:\nD:bar\n" + `\work\cwd-with-[[]and]\baz`, windows, map[string]MatchKind{
`C:\work\cwd-with-[and]`: MatchAll, `C:\work\cwd-with-[and]\foo`: MatchAll, `C:\foo`: MatchNone,
`D:\`: MatchDirectory, `D:\bar`: MatchAll, `D:\foo`: MatchNone, `C:\work\cwd-with-[and]\baz`: MatchAll,
}},
{"windows unc", `\\srv\share\a\*`, windows, map[string]MatchKind{`\\srv\share\a\b`: MatchAll, `//SRV/share/a/b`: MatchAll, `\\srv\other\a\b`: MatchNone}},
{"trailing separator matches directories only", "/foo/**/", posix, map[string]MatchKind{
"/": MatchNone, "/foo/": MatchDirectory, "/foo/bar": MatchDirectory, "/foo/bar/baz": MatchDirectory,
}},
{"backslash is a separator on windows", `C:\foo\`, windows, map[string]MatchKind{`C:\foo`: MatchDirectory}},
{"backslash escapes on posix", `/foo\*/b\[a]r`, posix, map[string]MatchKind{"/foo*/b[a]r": MatchAll, "/fooo/bar": MatchNone}},
{"interleaved excludes", "/**/*.proj\n/**/README.txt\n!/**/solution2/**\n/**/*.sln\n!/**/proj2/README.txt", posix, map[string]MatchKind{
"/solution1/proj1/proj1.proj": MatchAll, "/solution1/proj1/README.txt": MatchAll, "/solution1/proj2/README.txt": MatchNone,
"/solution1/solution1.sln": MatchAll, "/solution2/proj1/proj1.proj": MatchNone, "/solution2/solution2.sln": MatchAll,
}},
{"exclude directories", "C:/foo/**\n!C:/foo/**/", windows, map[string]MatchKind{
`C:\`: MatchNone, `C:\foo`: MatchFile, `C:\foo\bar`: MatchFile,
}},
{"dot is on, brace and extglob are off", "/a/*\n/b/{x,y}\n/c/+(x)\n/d/#e", posix, map[string]MatchKind{
"/a/.hidden": MatchAll, "/a/.": MatchNone, "/b/{x,y}": MatchAll, "/b/x": MatchNone, "/c/+(x)": MatchAll, "/c/x": MatchNone, "/d/#e": MatchAll,
}},
{"character classes", "/a/[!x]\n/b/[a-c]z\n/c/[z-a]\n/d/[]]", posix, map[string]MatchKind{
"/a/y": MatchAll, "/a/x": MatchNone, "/b/bz": MatchAll, "/b/dz": MatchNone, "/c/[z-a]": MatchAll, "/c/z": MatchNone, "/d/]": MatchAll,
}},
{"unclosed class splices undefined like minimatch 3", "C:/foo/b[\\!]r/b*", windows, map[string]MatchKind{
"C:/foo/b[undefined/!]r/baz": MatchAll,
}},
{"implicit descendants", "/foo\n/bar/**\n/dir/\n!/foo/skip", implicit(posix), map[string]MatchKind{
"/foo": MatchAll, "/foo/a/b": MatchAll, "/foo/skip": MatchNone, "/foo/skip/x": MatchNone,
"/bar": MatchAll, "/dir": MatchDirectory, "/dir/file": MatchAll,
}},
{"no implicit descendants", "/foo", posix, map[string]MatchKind{"/foo": MatchAll, "/foo/a": MatchNone}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g, err := Parse(tt.patterns, tt.opts)
require.NoError(t, err)
for p, want := range tt.want {
assert.Equal(t, want, g.Match(p), p)
}
})
}
}
func TestPartialMatch(t *testing.T) {
tests := []struct {
name string
patterns string
opts Options
want map[string]bool
}{
{"skips excludes", "/search1/foo/**\n/search2/bar/**\n!/search2/bar/**\n!/search3/baz/**", posix, map[string]bool{
"/search1": true, "/search1/foo": true, "/search2": true, "/search2/bar": true, "/search3": false, "/search3/bar": false,
}},
{"root", "/foo/**", posix, map[string]bool{"/": true}},
{"root on windows", "C:\\foo\\**\nd:\\foo\\**", windows, map[string]bool{`c:\`: true, `D:\`: true, `E:\`: false}},
{"case", "/Foo/Bar/**/Baz", posix, map[string]bool{"/Foo": true, "/fOO": false}},
{"case on windows", "C:/Foo/Bar/**/Baz", windows, map[string]bool{`C:\Foo`: true, `C:\fOO`: true, `C:\Other`: false}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g, err := Parse(tt.patterns, tt.opts)
require.NoError(t, err)
for p, want := range tt.want {
assert.Equal(t, want, g.PartialMatch(p), p)
}
})
}
}
func TestSearchPaths(t *testing.T) {
tests := []struct {
name string
patterns string
opts Options
want []string
}{
{"omits excludes", "/search1/foo/**\n/search2/bar/**\n!/search3/baz/**", posix, []string{"/search1/foo", "/search2/bar"}},
{
"omits descendants", "/search1/foo/**\n/search1/foo/bar/**\n/search2/foo/bar\n/search2/**\n/search3/foo/bar/**\n/search3/foo/bar/**", posix,
[]string{"/search1/foo", "/search2", "/search3/foo/bar"},
},
{
"omits descendants on windows", "C:\\Search1\\Foo\\**\nC:\\sEARCH1\\fOO\\bar\\**\nC:\\sEARCH1\\foo\\bar\nC:\\Search2\\**\nC:\\Search3\\Foo\\Bar\\**\nC:\\sEARCH3\\fOO\\bAR\\**", windows,
[]string{`C:\Search1\Foo`, `C:\Search2`, `C:\Search3\Foo\Bar`},
},
{"skips comments and blank lines", "# /nope\n\n /a/* \n", posix, []string{"/a"}},
{"splits carriage returns on windows", "C:/a/*\r\nC:/b/*\rC:/c/*", windows, []string{`C:\a`, `C:\b`, `C:\c`}},
{
"unescapes literal segments", "/foo/b[a]r/b*\n/x/b[*]r/b*\n/y/b[!]r/b*\n/z/b[]r/b*\n/q/b[aA]r/b*\n/e/b\\[a]r/b*\n/f/b[\\]]r/b*", posix,
[]string{"/foo/bar", "/x/b*r", "/y/b!r", "/z/b[]r", "/q", "/e/b[a]r", "/f/b]r"},
},
{"unescapes literal segments on windows", "C:/foo/b\\[a]r/b*\nC:/x/b[?]r/b*", windows, []string{`C:\foo\b\ar`, `C:\x\b?r`}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g, err := Parse(tt.patterns, tt.opts)
require.NoError(t, err)
assert.Equal(t, tt.want, g.SearchPaths())
})
}
}
func TestParseErrors(t *testing.T) {
tests := []struct {
name string
patterns string
opts Options
}{
{"empty after negation", "!", posix},
{"dot segment", "/a/./b", posix},
{"dot dot segment", "a/../b", posix},
{"glob in unc root", `\\srv\sh*re\a`, windows},
{"tilde without home", "~/a", Options{Workdir: "/w"}},
{"relative workdir", "a", Options{Workdir: "w"}},
{"drive relative with unc workdir", "C:a", Options{Workdir: `\\srv\share`, Windows: true}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse(tt.patterns, tt.opts)
assert.Error(t, err)
})
}
}
func TestLeastCommonAncestor(t *testing.T) {
tests := []struct {
paths []string
windows bool
want string
}{
{[]string{"/a/b/c", "/a/b/d", "/a/b"}, false, "/a/b"},
{[]string{"/foo", "/bar"}, false, "/"},
{[]string{"/a"}, false, "/a"},
{[]string{`C:\a\b`, `C:\a\c`}, true, `C:\a`},
{[]string{`C:\a`, `c:\a\b`}, true, "."},
{[]string{`C:\a`, `C:\b`}, true, "C:."},
{[]string{`\\srv\share\a`, `\\srv\share\b`}, true, `\srv\share`},
}
for _, tt := range tests {
assert.Equal(t, tt.want, LeastCommonAncestor(tt.paths, tt.windows), tt.paths)
}
}
File diff suppressed because it is too large. Load diff
+284
View File
@@ -0,0 +1,284 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package glob
import (
"errors"
"regexp"
"strings"
)
// flavour selects Windows or POSIX path semantics; the helpers port @actions/glob internal-path-helper.ts.
type flavour struct{ windows bool }
var (
reUNCPrefix = regexp.MustCompile(`^\\\\+[^\\]`)
reBackslashRun = regexp.MustCompile(`\\\\+`)
reSlashRun = regexp.MustCompile(`//+`)
reDriveRoot = regexp.MustCompile(`^[A-Za-z]:\\$`)
reDriveAbs = regexp.MustCompile(`^[A-Za-z]:\\`)
reDrive = regexp.MustCompile(`^[A-Za-z]:`)
reDriveOnly = regexp.MustCompile(`^[A-Za-z]:$`)
reDriveRel = regexp.MustCompile(`^[A-Za-z]:[^\\/]|^[A-Za-z]:$`)
reRelRoot = regexp.MustCompile(`^\\$|^\\[^\\]`)
reRelRootOnly = regexp.MustCompile(`^\\[^\\]`)
reDriveNoSep = regexp.MustCompile(`^[A-Za-z]:[^\\]`)
reUNCRoot = regexp.MustCompile(`^\\\\[^\\]+(\\[^\\]+)?$`)
reUNCRootSlash = regexp.MustCompile(`^\\\\[^\\]+\\[^\\]+\\$`)
)
func (w flavour) sep() string {
if w.windows {
return `\`
}
return "/"
}
func (w flavour) isSep(c byte) bool {
return c == '/' || (w.windows && c == '\\')
}
func (w flavour) normalizeSeparators(p string) string {
if w.windows {
p = strings.ReplaceAll(p, "/", `\`)
prefix := ""
if reUNCPrefix.MatchString(p) {
prefix = `\`
}
return prefix + reBackslashRun.ReplaceAllLiteralString(p, `\`)
}
return reSlashRun.ReplaceAllLiteralString(p, "/")
}
func (w flavour) safeTrimTrailingSeparator(p string) string {
if p == "" {
return ""
}
p = w.normalizeSeparators(p)
if !strings.HasSuffix(p, w.sep()) || p == w.sep() || (w.windows && reDriveRoot.MatchString(p)) {
return p
}
return p[:len(p)-1]
}
func (w flavour) hasAbsoluteRoot(p string) bool {
p = w.normalizeSeparators(p)
if w.windows {
return strings.HasPrefix(p, `\\`) || reDriveAbs.MatchString(p)
}
return strings.HasPrefix(p, "/")
}
func (w flavour) hasRoot(p string) bool {
p = w.normalizeSeparators(p)
if w.windows {
return strings.HasPrefix(p, `\`) || reDrive.MatchString(p)
}
return strings.HasPrefix(p, "/")
}
func (w flavour) dirname(p string) string {
p = w.safeTrimTrailingSeparator(p)
if w.windows && reUNCRoot.MatchString(p) {
return p
}
result := w.nodeDirname(p)
if w.windows && reUNCRootSlash.MatchString(result) {
result = w.safeTrimTrailingSeparator(result)
}
return result
}
// ensureAbsoluteRoot resolves Windows drive and root relative paths against cwd like process.cwd().
func (w flavour) ensureAbsoluteRoot(root, itemPath, cwd string) (string, error) {
if root == "" || itemPath == "" {
return "", errors.New("ensureAbsoluteRoot parameters must not be empty")
}
if w.hasAbsoluteRoot(itemPath) {
return itemPath, nil
}
if w.windows {
if reDriveRel.MatchString(itemPath) {
if !reDriveAbs.MatchString(cwd) {
return "", errors.New("expected current directory to start with an absolute drive root, actual '" + cwd + "'")
}
if strings.EqualFold(itemPath[:1], cwd[:1]) {
if len(itemPath) == 2 {
return itemPath[:1] + `:\` + cwd[3:], nil
}
if !strings.HasSuffix(cwd, `\`) {
cwd += `\`
}
return itemPath[:1] + `:\` + cwd[3:] + itemPath[2:], nil
}
return itemPath[:1] + `:\` + itemPath[2:], nil
} else if reRelRoot.MatchString(w.normalizeSeparators(itemPath)) {
if !reDriveAbs.MatchString(cwd) {
return "", errors.New("expected current directory to start with an absolute drive root, actual '" + cwd + "'")
}
return cwd[:1] + `:\` + itemPath[1:], nil
}
}
if !w.hasAbsoluteRoot(root) {
return "", errors.New("ensureAbsoluteRoot parameter 'root' must have an absolute root")
}
if !strings.HasSuffix(root, "/") && (!w.windows || !strings.HasSuffix(root, `\`)) {
root += w.sep()
}
return root + itemPath, nil
}
// splitPath ports internal-path.ts Path(string).segments.
func (w flavour) splitPath(p string) []string {
p = w.safeTrimTrailingSeparator(p)
if !w.hasRoot(p) {
return strings.Split(p, w.sep())
}
var segments []string
remaining := p
for dir := w.dirname(remaining); dir != remaining; dir = w.dirname(remaining) {
segments = append([]string{w.nodeBasename(remaining)}, segments...)
remaining = dir
}
return append([]string{remaining}, segments...)
}
// joinSegments ports internal-path.ts Path(string[]).toString().
func (w flavour) joinSegments(segments []string) string {
result := w.normalizeSeparators(segments[0])
if w.hasRoot(result) {
result = w.safeTrimTrailingSeparator(result)
}
skipSlash := strings.HasSuffix(result, w.sep()) || (w.windows && reDriveOnly.MatchString(result))
for _, s := range segments[1:] {
if skipSlash {
skipSlash = false
} else {
result += w.sep()
}
result += w.normalizeSeparators(s)
}
return result
}
func isDeviceRoot(c byte) bool {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
}
// nodeDirname ports Node's path.posix.dirname and path.win32.dirname.
func (w flavour) nodeDirname(p string) string {
n := len(p)
if n == 0 {
return "."
}
if !w.windows {
hasRoot := p[0] == '/'
end := -1
matchedSlash := true
for i := n - 1; i >= 1; i-- {
if p[i] == '/' {
if !matchedSlash {
end = i
break
}
} else {
matchedSlash = false
}
}
switch {
case end == -1 && hasRoot:
return "/"
case end == -1:
return "."
case hasRoot && end == 1:
return "//"
}
return p[:end]
}
if n == 1 {
if w.isSep(p[0]) {
return p
}
return "."
}
rootEnd, offset := -1, 0
if w.isSep(p[0]) {
rootEnd, offset = 1, 1
if w.isSep(p[1]) {
j := 2
last := j
for j < n && !w.isSep(p[j]) {
j++
}
if j < n && j != last {
last = j
for j < n && w.isSep(p[j]) {
j++
}
if j < n && j != last {
last = j
for j < n && !w.isSep(p[j]) {
j++
}
if j == n {
return p
}
if j != last {
rootEnd, offset = j+1, j+1
}
}
}
}
} else if isDeviceRoot(p[0]) && p[1] == ':' {
rootEnd = 2
if n > 2 && w.isSep(p[2]) {
rootEnd = 3
}
offset = rootEnd
}
end := -1
matchedSlash := true
for i := n - 1; i >= offset; i-- {
if w.isSep(p[i]) {
if !matchedSlash {
end = i
break
}
} else {
matchedSlash = false
}
}
if end == -1 {
if rootEnd == -1 {
return "."
}
end = rootEnd
}
return p[:end]
}
// nodeBasename ports Node's path.posix.basename and path.win32.basename without the ext argument.
func (w flavour) nodeBasename(p string) string {
start := 0
if w.windows && len(p) >= 2 && isDeviceRoot(p[0]) && p[1] == ':' {
start = 2
}
from, end := start, -1
matchedSlash := true
for i := len(p) - 1; i >= start; i-- {
if w.isSep(p[i]) {
if !matchedSlash {
from = i + 1
break
}
} else if end == -1 {
matchedSlash = false
end = i + 1
}
}
if end == -1 {
return ""
}
return p[from:end]
}
+269
View File
@@ -0,0 +1,269 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package glob
import (
"errors"
"fmt"
"strings"
"unicode/utf16"
)
// pattern ports @actions/glob internal-pattern.ts.
type pattern struct {
negate bool
searchPath string
segments []string
trailingSeparator bool
implicit bool
rootLiteral string
mm []mmSegment
w flavour
}
type parseEnv struct {
w flavour
cwd string
home func() (string, error)
}
func newPattern(s string, implicit bool, env parseEnv) (*pattern, error) {
w := env.w
p := &pattern{implicit: implicit, w: w}
s = jsTrim(s)
for strings.HasPrefix(s, "!") {
p.negate = !p.negate
s = jsTrim(s[1:])
}
s, err := fixupPattern(s, env)
if err != nil {
return nil, err
}
p.segments = w.splitPath(s)
p.trailingSeparator = strings.HasSuffix(w.normalizeSeparators(s), w.sep())
s = w.safeTrimTrailingSeparator(s)
var searchSegments []string
for _, seg := range p.segments {
lit := getLiteral(seg, w)
if lit == "" {
break
}
searchSegments = append(searchSegments, lit)
}
p.searchPath = w.joinSegments(searchSegments)
p.rootLiteral = searchSegments[0]
if w.windows {
s = strings.ReplaceAll(s, `\`, "/")
}
p.mm, err = compileMinimatch(s, w.windows)
if err != nil {
return nil, err
}
return p, nil
}
func fixupPattern(s string, env parseEnv) (string, error) {
w := env.w
if s == "" {
return "", errors.New("pattern cannot be empty")
}
segments := w.splitPath(s)
for i, seg := range segments {
if lit := getLiteral(seg, w); (lit == "." && i != 0) || lit == ".." {
return "", fmt.Errorf("invalid pattern '%s': relative pathing '.' and '..' is not allowed", s)
}
}
if w.hasRoot(s) && getLiteral(segments[0], w) == "" {
return "", fmt.Errorf("invalid pattern '%s': root segment must not contain globs", s)
}
s = w.normalizeSeparators(s)
sep := w.sep()
var root string
var err error
switch {
case s == "." || strings.HasPrefix(s, "."+sep):
s = globEscape(env.cwd, w) + s[1:]
case s == "~" || strings.HasPrefix(s, "~"+sep):
var home string
if env.home != nil {
if home, err = env.home(); err != nil {
return "", err
}
}
if home == "" {
return "", errors.New("unable to determine HOME directory")
}
if home = w.normalizeSeparators(home); !w.hasAbsoluteRoot(home) {
return "", fmt.Errorf("expected HOME directory to be a rooted path, actual '%s'", home)
}
s = globEscape(home, w) + s[1:]
case w.windows && (reDriveOnly.MatchString(s) || reDriveNoSep.MatchString(s)):
if root, err = w.ensureAbsoluteRoot(`C:\dummy-root`, s[:2], env.cwd); err != nil {
return "", err
}
if len(s) > 2 && !strings.HasSuffix(root, `\`) {
root += `\`
}
s = globEscape(root, w) + s[2:]
case w.windows && (s == `\` || reRelRootOnly.MatchString(s)):
if root, err = w.ensureAbsoluteRoot(`C:\dummy-root`, `\`, env.cwd); err != nil {
return "", err
}
if !strings.HasSuffix(root, `\`) {
root += `\`
}
s = globEscape(root, w) + s[1:]
default:
if s, err = w.ensureAbsoluteRoot(globEscape(env.cwd, w), s, env.cwd); err != nil {
return "", err
}
}
return w.normalizeSeparators(s), nil
}
func (p *pattern) match(itemPath string) MatchKind {
w := p.w
if p.segments[len(p.segments)-1] == "**" {
itemPath = w.normalizeSeparators(itemPath)
// Without the trailing separator minimatch would not match the directory preceding the globstar.
if !strings.HasSuffix(itemPath, w.sep()) && !p.implicit {
itemPath += w.sep()
}
} else {
itemPath = w.safeTrimTrailingSeparator(itemPath)
}
if w.windows {
itemPath = strings.ReplaceAll(itemPath, `\`, "/")
}
if !matchOne(splitRuns(itemPath, '/'), p.mm, false) {
return MatchNone
}
if p.trailingSeparator {
return MatchDirectory
}
return MatchAll
}
func (p *pattern) partialMatch(itemPath string) bool {
w := p.w
itemPath = w.safeTrimTrailingSeparator(itemPath)
// The toolkit tests roots with an unanchored regexp because minimatch mishandles them.
if w.dirname(itemPath) == itemPath {
if w.windows {
return strings.Contains(strings.ToUpper(itemPath), strings.ToUpper(p.rootLiteral))
}
return strings.Contains(itemPath, p.rootLiteral)
}
sep := byte('/')
if w.windows {
sep = '\\'
}
return matchOne(splitRuns(itemPath, sep), p.mm, true)
}
// globEscape ports Pattern.globEscape.
func globEscape(s string, w flavour) string {
if !w.windows {
s = strings.ReplaceAll(s, `\`, `\\`)
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '[' && closesBeforeSlash(s[i+1:]) {
b.WriteString("[[]")
continue
}
b.WriteByte(s[i])
}
s = strings.ReplaceAll(b.String(), "?", "[?]")
return strings.ReplaceAll(s, "*", "[*]")
}
// closesBeforeSlash reports whether s matches the lookahead `[^/]+\]`.
func closesBeforeSlash(s string) bool {
for i := 0; i < len(s) && s[i] != '/'; i++ {
if s[i] == ']' && i > 0 {
return true
}
}
return false
}
// getLiteral ports Pattern.getLiteral, returning "" when the segment contains a glob.
func getLiteral(segment string, w flavour) string {
seg := utf16.Encode([]rune(segment))
var lit []uint16
for i := 0; i < len(seg); i++ {
c := seg[i]
switch {
case c == '\\' && !w.windows && i+1 < len(seg):
i++
lit = append(lit, seg[i])
continue
case c == '*' || c == '?':
return ""
case c == '[' && i+1 < len(seg):
var set []uint16
closed := -1
for i2 := i + 1; i2 < len(seg); i2++ {
c2 := seg[i2]
if c2 == '\\' && !w.windows && i2+1 < len(seg) {
i2++
set = append(set, seg[i2])
continue
} else if c2 == ']' {
closed = i2
break
}
set = append(set, c2)
}
if closed >= 0 {
if len(set) > 1 {
return ""
}
if len(set) == 1 {
lit = append(lit, set...)
i = closed
continue
}
}
}
lit = append(lit, c)
}
return string(utf16.Decode(lit))
}
func isJSWhitespace(r rune) bool {
switch r {
case '\t', '\n', '\v', '\f', '\r', ' ', 0xa0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff:
return true
}
return r >= 0x2000 && r <= 0x200a
}
// jsTrim matches String.prototype.trim, which differs from strings.TrimSpace on U+0085 and U+FEFF.
func jsTrim(s string) string {
return strings.TrimFunc(s, isJSWhitespace)
}
// splitRuns splits like JS s.split(/c+/).
func splitRuns(s string, c byte) []string {
parts := []string{}
start := 0
for i := 0; i < len(s); {
if s[i] != c {
i++
continue
}
parts = append(parts, s[start:i])
for i < len(s) && s[i] == c {
i++
}
start = i
}
return append(parts, s[start:])
}