Files
runner/internal/pkg/action/glob/path.go
T
bircniandsilverwind b88d111cc5 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]>
2026-11-06 01:13:07 +00:00

285 lines
6.7 KiB
Go

// 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]
}