aboutsummaryrefslogtreecommitdiff
path: root/ref/refname
diff options
context:
space:
mode:
Diffstat (limited to 'ref/refname')
-rw-r--r--ref/refname/branch.go25
-rw-r--r--ref/refname/component.go88
-rw-r--r--ref/refname/current.go5
-rw-r--r--ref/refname/disposition.go20
-rw-r--r--ref/refname/errors.go14
-rw-r--r--ref/refname/flags.go6
-rw-r--r--ref/refname/length.go11
-rw-r--r--ref/refname/lock.go3
-rw-r--r--ref/refname/normalize.go53
-rw-r--r--ref/refname/options.go30
-rw-r--r--ref/refname/pseudo.go11
-rw-r--r--ref/refname/refname_test.go622
-rw-r--r--ref/refname/root.go21
-rw-r--r--ref/refname/root_syntax.go13
-rw-r--r--ref/refname/safe.go31
-rw-r--r--ref/refname/sanitize.go19
-rw-r--r--ref/refname/slashes.go26
-rw-r--r--ref/refname/tag.go20
-rw-r--r--ref/refname/update.go56
-rw-r--r--ref/refname/utils.go22
-rw-r--r--ref/refname/validate.go65
-rw-r--r--ref/refname/worktree.go75
22 files changed, 1236 insertions, 0 deletions
diff --git a/ref/refname/branch.go b/ref/refname/branch.go
new file mode 100644
index 00000000..274a95e3
--- /dev/null
+++ b/ref/refname/branch.go
@@ -0,0 +1,25 @@
+package refname
+
+import "strings"
+
+// Branch checks one branch shorthand and returns its fully-qualified
+// refs/heads/... name.
+//
+// Unlike Git in-repository branch parsing, this helper does not expand @{-n}.
+func Branch(name string) (string, error) {
+ full := "refs/heads/" + name
+ if strings.HasPrefix(name, "-") || full == "refs/heads/HEAD" {
+ return "", &NameError{Name: name, Reason: "invalid branch name"}
+ }
+
+ err := validate(full, 0)
+ if err != nil {
+ return "", err
+ }
+
+ if strings.HasPrefix(name, "refs/") {
+ return name, nil
+ }
+
+ return full, nil
+}
diff --git a/ref/refname/component.go b/ref/refname/component.go
new file mode 100644
index 00000000..f5adba46
--- /dev/null
+++ b/ref/refname/component.go
@@ -0,0 +1,88 @@
+package refname
+
+import "strings"
+
+func checkRefnameComponent(name string, flags *int, sanitized *strings.Builder, fullName string) (int, error) {
+ var last byte
+
+ componentStart := sanitizedLen(sanitized)
+
+ for i := range len(name) {
+ ch := name[i]
+ disp := refnameDisposition(ch)
+
+ if sanitized != nil && disp != 1 {
+ sanitized.WriteByte(ch)
+ }
+
+ switch disp {
+ case 1:
+ goto out
+ case 2:
+ if last == '.' {
+ if sanitized != nil {
+ truncateBuilder(sanitized, sanitized.Len()-1)
+ } else {
+ return 0, &NameError{Name: fullName, Reason: "name contains '..'"}
+ }
+ }
+ case 3:
+ if last == '@' {
+ if sanitized != nil {
+ overwriteLastByte(sanitized, '-')
+ } else {
+ return 0, &NameError{Name: fullName, Reason: "name contains '@{'"}
+ }
+ }
+ case 4:
+ if sanitized != nil {
+ overwriteLastByte(sanitized, '-')
+ } else {
+ return 0, &NameError{Name: fullName, Reason: "name contains one forbidden character"}
+ }
+ case 5:
+ if *flags&refnameRefspecPattern == 0 {
+ if sanitized != nil {
+ overwriteLastByte(sanitized, '-')
+ } else {
+ return 0, &NameError{Name: fullName, Reason: "name contains '*'"}
+ }
+ }
+
+ *flags &^= refnameRefspecPattern
+ }
+
+ last = ch
+ }
+
+out:
+ componentLen := strings.IndexByte(name, '/')
+
+ if componentLen < 0 {
+ componentLen = len(name)
+ }
+
+ if componentLen == 0 {
+ return 0, nil
+ }
+
+ if name[0] == '.' {
+ if sanitized != nil {
+ overwriteBuilderAt(sanitized, componentStart, '-')
+ } else {
+ return 0, &NameError{Name: fullName, Reason: "component starts with '.'"}
+ }
+ }
+
+ if componentLen >= len(lockSuffix) && name[componentLen-len(lockSuffix):componentLen] == lockSuffix {
+ if sanitized == nil {
+ return 0, &NameError{Name: fullName, Reason: "component ends with .lock"}
+ }
+
+ for strings.HasSuffix(sanitized.String(), lockSuffix) {
+ truncateBuilder(sanitized, sanitized.Len()-len(lockSuffix))
+ }
+ }
+
+ return componentLen, nil
+}
diff --git a/ref/refname/current.go b/ref/refname/current.go
new file mode 100644
index 00000000..3a5394cc
--- /dev/null
+++ b/ref/refname/current.go
@@ -0,0 +1,5 @@
+package refname
+
+func isCurrentWorktreeRef(name string) bool {
+ return IsRootSyntax(name) || IsPerWorktree(name)
+}
diff --git a/ref/refname/disposition.go b/ref/refname/disposition.go
new file mode 100644
index 00000000..5153e633
--- /dev/null
+++ b/ref/refname/disposition.go
@@ -0,0 +1,20 @@
+package refname
+
+func refnameDisposition(ch byte) byte {
+ switch {
+ case ch == '/':
+ return 1
+ case ch == '.':
+ return 2
+ case ch == '{':
+ return 3
+ case ch == '*':
+ return 5
+ case ch < 0x20 || ch == 0x7f:
+ return 4
+ case ch == ':' || ch == '?' || ch == '[' || ch == '\\' || ch == '^' || ch == '~' || ch == ' ' || ch == '\t':
+ return 4
+ default:
+ return 0
+ }
+}
diff --git a/ref/refname/errors.go b/ref/refname/errors.go
new file mode 100644
index 00000000..e39bc73b
--- /dev/null
+++ b/ref/refname/errors.go
@@ -0,0 +1,14 @@
+package refname
+
+import "fmt"
+
+// NameError reports one invalid reference name.
+type NameError struct {
+ Name string
+ Reason string
+}
+
+// Error implements error.
+func (err *NameError) Error() string {
+ return fmt.Sprintf("ref: invalid name %q: %s", err.Name, err.Reason)
+}
diff --git a/ref/refname/flags.go b/ref/refname/flags.go
new file mode 100644
index 00000000..72f0a58f
--- /dev/null
+++ b/ref/refname/flags.go
@@ -0,0 +1,6 @@
+package refname
+
+const (
+ refnameAllowOneLevel = 1 << iota
+ refnameRefspecPattern
+)
diff --git a/ref/refname/length.go b/ref/refname/length.go
new file mode 100644
index 00000000..94c0322b
--- /dev/null
+++ b/ref/refname/length.go
@@ -0,0 +1,11 @@
+package refname
+
+import "strings"
+
+func sanitizedLen(builder *strings.Builder) int {
+ if builder == nil {
+ return 0
+ }
+
+ return builder.Len()
+}
diff --git a/ref/refname/lock.go b/ref/refname/lock.go
new file mode 100644
index 00000000..33db902f
--- /dev/null
+++ b/ref/refname/lock.go
@@ -0,0 +1,3 @@
+package refname
+
+const lockSuffix = ".lock"
diff --git a/ref/refname/normalize.go b/ref/refname/normalize.go
new file mode 100644
index 00000000..9cbe7126
--- /dev/null
+++ b/ref/refname/normalize.go
@@ -0,0 +1,53 @@
+package refname
+
+import "strings"
+
+// Normalize collapses slashes according to what Git wants
+// then validates the normalized name.
+func Normalize(name string, options Options) (string, error) {
+ normalized := collapseSlashes(name)
+
+ err := validate(normalized, options.flags())
+ if err != nil {
+ return "", err
+ }
+
+ return normalized, nil
+}
+
+func normalizeRefPath(path string) (string, bool) {
+ components := make([]string, 0, strings.Count(path, "/")+1)
+ i := 0
+
+ for i < len(path) {
+ for i < len(path) && path[i] == '/' {
+ i++
+ }
+
+ if i == len(path) {
+ break
+ }
+
+ j := i
+ for j < len(path) && path[j] != '/' {
+ j++
+ }
+
+ component := path[i:j]
+ switch component {
+ case ".":
+ case "..":
+ if len(components) == 0 {
+ return "", false
+ }
+
+ components = components[:len(components)-1]
+ default:
+ components = append(components, component)
+ }
+
+ i = j
+ }
+
+ return strings.Join(components, "/"), true
+}
diff --git a/ref/refname/options.go b/ref/refname/options.go
new file mode 100644
index 00000000..5ae81541
--- /dev/null
+++ b/ref/refname/options.go
@@ -0,0 +1,30 @@
+package refname
+
+import "fmt"
+
+// Options controls Git refname validation.
+type Options struct {
+ // AllowOneLevel permits one-component refnames like HEAD.
+ AllowOneLevel bool
+
+ // RefspecPattern permits one '*' anywhere in the refname.
+ RefspecPattern bool
+}
+
+// String returns one stable text form of the options.
+func (options Options) String() string {
+ return fmt.Sprintf("allow_onelevel=%t,refspec_pattern=%t", options.AllowOneLevel, options.RefspecPattern)
+}
+
+func (options Options) flags() int {
+ var flags int
+ if options.AllowOneLevel {
+ flags |= refnameAllowOneLevel
+ }
+
+ if options.RefspecPattern {
+ flags |= refnameRefspecPattern
+ }
+
+ return flags
+}
diff --git a/ref/refname/pseudo.go b/ref/refname/pseudo.go
new file mode 100644
index 00000000..f0ad1ae8
--- /dev/null
+++ b/ref/refname/pseudo.go
@@ -0,0 +1,11 @@
+package refname
+
+// IsPseudo reports whether name is one Git pseudo-ref.
+func IsPseudo(name string) bool {
+ switch name {
+ case "FETCH_HEAD", "MERGE_HEAD":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/ref/refname/refname_test.go b/ref/refname/refname_test.go
new file mode 100644
index 00000000..5b26196f
--- /dev/null
+++ b/ref/refname/refname_test.go
@@ -0,0 +1,622 @@
+package refname_test
+
+import (
+ "context"
+ "os/exec"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/ref/refname"
+)
+
+func TestValidateNameAgainstGit(t *testing.T) {
+ t.Parallel()
+
+ type testCase struct {
+ name string
+ opts refname.Options
+ }
+
+ tests := []testCase{
+ {name: ""},
+ {name: "/"},
+ {name: "/", opts: refname.Options{AllowOneLevel: true}},
+ {name: "foo/bar/baz"},
+ {name: "refs/heads/main"},
+ {name: "refs/tags/v1.0.0"},
+ {name: "refs///heads/foo"},
+ {name: "heads/foo/"},
+ {name: "/heads/foo"},
+ {name: "///heads/foo"},
+ {name: "./foo"},
+ {name: "./foo/bar"},
+ {name: "foo/./bar"},
+ {name: "foo/bar/."},
+ {name: ".refs/foo"},
+ {name: "refs/heads/foo."},
+ {name: "HEAD"},
+ {name: "HEAD", opts: refname.Options{AllowOneLevel: true}},
+ {name: "refs/heads/.main"},
+ {name: "heads/foo..bar"},
+ {name: "refs/heads/main.lock"},
+ {name: "heads///foo.lock"},
+ {name: "refs/heads/foo..bar"},
+ {name: "refs/heads/foo bar"},
+ {name: "refs/heads/foo@{bar"},
+ {name: "heads/foo?bar"},
+ {name: "foo./bar"},
+ {name: "foo.lock/bar"},
+ {name: "foo.lock///bar"},
+ {name: "heads/foo@bar"},
+ {name: "heads/foo\\bar"},
+ {name: "heads/foo\tbar"},
+ {name: "heads/foo\x7fbar"},
+ {name: "heads/fu\xC3\x9F"},
+ {name: "heads/*foo/bar", opts: refname.Options{RefspecPattern: true}},
+ {name: "heads/foo*/bar", opts: refname.Options{RefspecPattern: true}},
+ {name: "heads/f*o/bar", opts: refname.Options{RefspecPattern: true}},
+ {name: "heads/f*o*/bar", opts: refname.Options{RefspecPattern: true}},
+ {name: "heads/foo*/bar*", opts: refname.Options{RefspecPattern: true}},
+ {name: "refs/heads/foo/bar."},
+ {name: "refs//heads///main"},
+ {name: "foo"},
+ {name: "foo", opts: refname.Options{AllowOneLevel: true}},
+ {name: "foo", opts: refname.Options{RefspecPattern: true}},
+ {name: "foo", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ {name: "foo/bar"},
+ {name: "foo/bar", opts: refname.Options{AllowOneLevel: true}},
+ {name: "foo/bar", opts: refname.Options{RefspecPattern: true}},
+ {name: "foo/bar", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ {name: "refs/heads/*"},
+ {name: "refs/heads/*", opts: refname.Options{RefspecPattern: true}},
+ {name: "refs/heads/feature*branch", opts: refname.Options{RefspecPattern: true}},
+ {name: "refs/heads/foo*bar*baz", opts: refname.Options{RefspecPattern: true}},
+ {name: "foo/*"},
+ {name: "foo/*", opts: refname.Options{RefspecPattern: true}},
+ {name: "foo/*", opts: refname.Options{AllowOneLevel: true}},
+ {name: "foo/*", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ {name: "*/foo"},
+ {name: "*/foo", opts: refname.Options{RefspecPattern: true}},
+ {name: "*/foo", opts: refname.Options{AllowOneLevel: true}},
+ {name: "*/foo", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ {name: "foo/*/bar"},
+ {name: "foo/*/bar", opts: refname.Options{RefspecPattern: true}},
+ {name: "foo/*/bar", opts: refname.Options{AllowOneLevel: true}},
+ {name: "foo/*/bar", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ {name: "*"},
+ {name: "*", opts: refname.Options{AllowOneLevel: true}},
+ {name: "*", opts: refname.Options{RefspecPattern: true}},
+ {name: "*", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ {name: "foo/*/*", opts: refname.Options{RefspecPattern: true}},
+ {name: "foo/*/*", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ {name: "*/foo/*", opts: refname.Options{RefspecPattern: true}},
+ {name: "*/foo/*", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ {name: "*/*/foo", opts: refname.Options{RefspecPattern: true}},
+ {name: "*/*/foo", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ {name: "/foo"},
+ {name: "/foo", opts: refname.Options{AllowOneLevel: true}},
+ {name: "/foo", opts: refname.Options{RefspecPattern: true}},
+ {name: "/foo", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ {name: "@"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name+"_"+tt.opts.String(), func(t *testing.T) {
+ t.Parallel()
+
+ err := refname.Validate(tt.name, tt.opts)
+ gitErr := gitCheckRefFormat(t, tt.name, tt.opts)
+
+ if (err == nil) != (gitErr == nil) {
+ t.Fatalf("ValidateName(%q, %+v) err=%v, git err=%v", tt.name, tt.opts, err, gitErr)
+ }
+ })
+ }
+}
+
+func TestNormalizeNameAgainstGit(t *testing.T) {
+ t.Parallel()
+
+ type testCase struct {
+ name string
+ opts refname.Options
+ }
+
+ tests := []testCase{
+ {name: "/"},
+ {name: "/", opts: refname.Options{AllowOneLevel: true}},
+ {name: "///refs///heads//main"},
+ {name: "refs////tags///v1"},
+ {name: "refs///heads///"},
+ {name: "HEAD", opts: refname.Options{AllowOneLevel: true}},
+ {name: "refs/heads/*", opts: refname.Options{RefspecPattern: true}},
+ {name: "refs///heads/foo"},
+ {name: "/heads/foo", opts: refname.Options{AllowOneLevel: true}},
+ {name: "///heads/foo"},
+ {name: "heads/foo/../bar"},
+ {name: "heads/./foo"},
+ {name: "heads\\foo"},
+ {name: "heads/foo.lock"},
+ {name: "heads///foo.lock"},
+ {name: "foo.lock/bar"},
+ {name: "foo.lock///bar"},
+ {name: "foo"},
+ {name: "/foo", opts: refname.Options{AllowOneLevel: true}},
+ {name: "/foo", opts: refname.Options{AllowOneLevel: true, RefspecPattern: true}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name+"_"+tt.opts.String(), func(t *testing.T) {
+ t.Parallel()
+
+ got, err := refname.Normalize(tt.name, tt.opts)
+ want, gitErr := gitNormalizeRefFormat(t, tt.name, tt.opts)
+
+ if (err == nil) != (gitErr == nil) {
+ t.Fatalf("NormalizeName(%q, %+v) err=%v, git err=%v", tt.name, tt.opts, err, gitErr)
+ }
+
+ if err == nil && got != want {
+ t.Fatalf("NormalizeName(%q, %+v) = %q, want %q", tt.name, tt.opts, got, want)
+ }
+ })
+ }
+}
+
+func TestBranchNameAgainstGit(t *testing.T) {
+ t.Parallel()
+
+ tests := []string{
+ "main",
+ "feature/topic",
+ "-main",
+ "HEAD",
+ "@{-1}",
+ "feature.lock",
+ "topic@{1}",
+ "refs/heads/main",
+ "refs/heads/HEAD",
+ "refs/tags/x",
+ }
+
+ for _, name := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := refname.Branch(name)
+ want, gitErr := gitCheckBranchName(t, name)
+
+ if (err == nil) != (gitErr == nil) {
+ t.Fatalf("BranchName(%q) err=%v, git err=%v", name, err, gitErr)
+ }
+
+ if err == nil && got != want {
+ t.Fatalf("BranchName(%q) = %q, want %q", name, got, want)
+ }
+ })
+ }
+}
+
+func TestTagName(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ }{
+ {name: "v1.0.0"},
+ {name: "main/topic"},
+ {name: "-bad"},
+ {name: "HEAD"},
+ {name: "feature.lock"},
+ {name: "refs/tags/v1.0.0"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := refname.Tag(tt.name)
+ want, gitErr := gitCheckTagName(t, tt.name)
+
+ if (err == nil) != (gitErr == nil) {
+ t.Fatalf("TagName(%q) err=%v, git err=%v", tt.name, err, gitErr)
+ }
+
+ if err == nil && got != want {
+ t.Fatalf("TagName(%q) = %q, want %q", tt.name, got, want)
+ }
+ })
+ }
+}
+
+func TestIsSafeName(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ want bool
+ }{
+ {name: "", want: false},
+ {name: "HEAD", want: true},
+ {name: "MERGE_HEAD", want: true},
+ {name: "Head", want: false},
+ {name: "refs/heads/main", want: true},
+ {name: "refs/", want: false},
+ {name: "refs//heads/main", want: false},
+ {name: "refs/heads/main/", want: false},
+ {name: "refs/foo/../bar", want: false},
+ {name: "refs/foo/../../bar", want: false},
+ {name: "refs/heads/main.lock", want: true},
+ }
+
+ for _, tt := range tests {
+ if got := refname.IsSafe(tt.name); got != tt.want {
+ t.Fatalf("IsSafeName(%q) = %v, want %v", tt.name, got, tt.want)
+ }
+ }
+}
+
+func TestIsPerWorktree(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ want bool
+ }{
+ {name: "refs/worktree/foo", want: true},
+ {name: "refs/bisect/foo", want: true},
+ {name: "refs/rewritten/foo", want: true},
+ {name: "refs/heads/foo", want: false},
+ {name: "worktrees/wt1/HEAD", want: false},
+ }
+
+ for _, tt := range tests {
+ if got := refname.IsPerWorktree(tt.name); got != tt.want {
+ t.Fatalf("IsPerWorktree(%q) = %v, want %v", tt.name, got, tt.want)
+ }
+ }
+}
+
+func TestIsPseudo(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ want bool
+ }{
+ {name: "FETCH_HEAD", want: true},
+ {name: "MERGE_HEAD", want: true},
+ {name: "HEAD", want: false},
+ {name: "AUTO_MERGE", want: false},
+ }
+
+ for _, tt := range tests {
+ if got := refname.IsPseudo(tt.name); got != tt.want {
+ t.Fatalf("IsPseudo(%q) = %v, want %v", tt.name, got, tt.want)
+ }
+ }
+}
+
+func TestIsRootSyntax(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ want bool
+ }{
+ {name: "", want: true},
+ {name: "HEAD", want: true},
+ {name: "AUTO_MERGE", want: true},
+ {name: "BISECT-EXPECTED_REV", want: true},
+ {name: "refs/heads/main", want: false},
+ {name: "Head", want: false},
+ {name: "HEAD1", want: false},
+ }
+
+ for _, tt := range tests {
+ if got := refname.IsRootSyntax(tt.name); got != tt.want {
+ t.Fatalf("IsRootSyntax(%q) = %v, want %v", tt.name, got, tt.want)
+ }
+ }
+}
+
+func TestIsRoot(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ want bool
+ }{
+ {name: "HEAD", want: true},
+ {name: "ORIG_HEAD", want: true},
+ {name: "BOGUS_HEAD", want: true},
+ {name: "CHERRY_PICK_HEAD", want: true},
+ {name: "REVERT_HEAD", want: true},
+ {name: "AUTO_MERGE", want: true},
+ {name: "BISECT_EXPECTED_REV", want: true},
+ {name: "NOTES_MERGE_PARTIAL", want: true},
+ {name: "NOTES_MERGE_REF", want: true},
+ {name: "MERGE_AUTOSTASH", want: true},
+ {name: "FETCH_HEAD", want: false},
+ {name: "MERGE_HEAD", want: false},
+ {name: "Head", want: false},
+ {name: "refs/heads/main", want: false},
+ }
+
+ for _, tt := range tests {
+ if got := refname.IsRoot(tt.name); got != tt.want {
+ t.Fatalf("IsRoot(%q) = %v, want %v", tt.name, got, tt.want)
+ }
+ }
+}
+
+func TestParseWorktree(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ want refname.ParsedWorktreeRef
+ }{
+ {
+ name: "refs/heads/main",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeShared,
+ BareRefName: "refs/heads/main",
+ },
+ },
+ {
+ name: "HEAD",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeCurrent,
+ BareRefName: "HEAD",
+ },
+ },
+ {
+ name: "refs/worktree/foo",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeCurrent,
+ BareRefName: "refs/worktree/foo",
+ },
+ },
+ {
+ name: "main-worktree/HEAD",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeMain,
+ BareRefName: "HEAD",
+ },
+ },
+ {
+ name: "main-worktree/FOO",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeMain,
+ BareRefName: "FOO",
+ },
+ },
+ {
+ name: "main-worktree/refs/worktree/foo",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeMain,
+ BareRefName: "refs/worktree/foo",
+ },
+ },
+ {
+ name: "main-worktree/",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeMain,
+ BareRefName: "",
+ },
+ },
+ {
+ name: "main-worktree/refs/heads/main",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeShared,
+ BareRefName: "main-worktree/refs/heads/main",
+ },
+ },
+ {
+ name: "worktrees/wt1/HEAD",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeOther,
+ WorktreeName: "wt1",
+ BareRefName: "HEAD",
+ },
+ },
+ {
+ name: "worktrees/wt1/BAR",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeOther,
+ WorktreeName: "wt1",
+ BareRefName: "BAR",
+ },
+ },
+ {
+ name: "worktrees/wt1/refs/bisect/foo",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeOther,
+ WorktreeName: "wt1",
+ BareRefName: "refs/bisect/foo",
+ },
+ },
+ {
+ name: "worktrees/wt1/refs/heads/main",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeShared,
+ BareRefName: "worktrees/wt1/refs/heads/main",
+ },
+ },
+ {
+ name: "worktrees/wt1",
+ want: refname.ParsedWorktreeRef{
+ Type: refname.WorktreeOther,
+ WorktreeName: "wt1",
+ BareRefName: "",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ if got := refname.ParseWorktree(tt.name); got != tt.want {
+ t.Fatalf("ParseWorktree(%q) = %#v, want %#v", tt.name, got, tt.want)
+ }
+ }
+}
+
+func TestValidateUpdateName(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ hasNewValue bool
+ wantErr bool
+ }{
+ {name: "refs/heads/main", hasNewValue: true, wantErr: false},
+ {name: "HEAD", hasNewValue: true, wantErr: false},
+ {name: "PSEUDOREF", hasNewValue: true, wantErr: false},
+ {name: "FETCH_HEAD", hasNewValue: true, wantErr: true},
+ {name: "MERGE_HEAD", hasNewValue: true, wantErr: true},
+ {name: "refs/heads/.bad", hasNewValue: true, wantErr: true},
+ {name: "foo/bar", hasNewValue: true, wantErr: false},
+ {name: "foo/bar", hasNewValue: false, wantErr: true},
+ {name: "PSEUDOREF", hasNewValue: false, wantErr: false},
+ {name: "HEAD", hasNewValue: false, wantErr: false},
+ }
+
+ for _, tt := range tests {
+ err := refname.ValidateUpdateName(tt.name, tt.hasNewValue)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("ValidateUpdateName(%q, %v) err=%v, wantErr=%v", tt.name, tt.hasNewValue, err, tt.wantErr)
+ }
+ }
+}
+
+func TestValidateSymbolicTarget(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ ref string
+ target string
+ wantErr bool
+ }{
+ {ref: "HEAD", target: "refs/heads/main", wantErr: false},
+ {ref: "HEAD", target: "foo", wantErr: true},
+ {ref: "HEAD", target: "ORIG_HEAD", wantErr: true},
+ {ref: "refs/heads/top", target: "ORIG_HEAD", wantErr: false},
+ {ref: "refs/heads/top", target: "refs/heads/main", wantErr: false},
+ {ref: "refs/heads/top", target: "worktrees/wt1/HEAD", wantErr: false},
+ {ref: "refs/heads/top", target: "foo", wantErr: true},
+ {ref: "refs/heads/top", target: "foo..bar", wantErr: true},
+ {ref: "main-worktree/HEAD", target: "refs/heads/main", wantErr: false},
+ {ref: "main-worktree/HEAD", target: "refs/tags/v1", wantErr: true},
+ }
+
+ for _, tt := range tests {
+ err := refname.ValidateSymbolicTarget(tt.ref, tt.target)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("ValidateSymbolicTarget(%q, %q) err=%v, wantErr=%v", tt.ref, tt.target, err, tt.wantErr)
+ }
+ }
+}
+
+func TestSanitizeComponent(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ component string
+ want string
+ }{
+ {component: ".", want: "-"},
+ {component: "..", want: "-"},
+ {component: "foo..bar", want: "foo.bar"},
+ {component: "foo.lock", want: "foo"},
+ {component: "foo.lock.lock", want: "foo"},
+ {component: "foo bar", want: "foo-bar"},
+ {component: "@", want: "-/@"},
+ {component: "a@{b", want: "a@-b"},
+ {component: "a*b", want: "a-b"},
+ }
+
+ for _, tt := range tests {
+ if got := refname.SanitizeComponent(tt.component); got != tt.want {
+ t.Fatalf("SanitizeComponent(%q) = %q, want %q", tt.component, got, tt.want)
+ }
+ }
+}
+
+func gitCheckRefFormat(tb testing.TB, name string, opts refname.Options) error {
+ tb.Helper()
+
+ args := []string{"check-ref-format"}
+ if opts.AllowOneLevel {
+ args = append(args, "--allow-onelevel")
+ }
+
+ if opts.RefspecPattern {
+ args = append(args, "--refspec-pattern")
+ }
+
+ args = append(args, name)
+
+ return exec.CommandContext(context.Background(), "git", args...).Run()
+}
+
+func gitNormalizeRefFormat(tb testing.TB, name string, opts refname.Options) (string, error) {
+ tb.Helper()
+
+ args := []string{"check-ref-format", "--normalize"}
+ if opts.AllowOneLevel {
+ args = append(args, "--allow-onelevel")
+ }
+
+ if opts.RefspecPattern {
+ args = append(args, "--refspec-pattern")
+ }
+
+ args = append(args, name)
+
+ out, err := exec.CommandContext(context.Background(), "git", args...).Output()
+ if err != nil {
+ return "", err
+ }
+
+ return strings.TrimSuffix(string(out), "\n"), nil
+}
+
+func gitCheckBranchName(tb testing.TB, name string) (string, error) {
+ tb.Helper()
+
+ cmd := exec.CommandContext(context.Background(), "git", "check-ref-format", "--branch", name)
+ cmd.Dir = tb.TempDir()
+
+ out, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+
+ branchName := strings.TrimSuffix(string(out), "\n")
+ if strings.HasPrefix(branchName, "refs/") {
+ return branchName, nil
+ }
+
+ return "refs/heads/" + branchName, nil
+}
+
+func gitCheckTagName(tb testing.TB, name string) (string, error) {
+ tb.Helper()
+
+ if strings.HasPrefix(name, "-") || name == "HEAD" {
+ return "", exec.ErrNotFound
+ }
+
+ //nolint:gosec
+ err := exec.CommandContext(
+ context.Background(),
+ "git",
+ "check-ref-format",
+ "refs/tags/"+name,
+ ).Run()
+ if err != nil {
+ return "", err
+ }
+
+ return "refs/tags/" + name, nil
+}
diff --git a/ref/refname/root.go b/ref/refname/root.go
new file mode 100644
index 00000000..43361846
--- /dev/null
+++ b/ref/refname/root.go
@@ -0,0 +1,21 @@
+package refname
+
+import "strings"
+
+// IsRoot reports whether name is one root ref according to Git.
+func IsRoot(name string) bool {
+ if !IsRootSyntax(name) || IsPseudo(name) {
+ return false
+ }
+
+ if strings.HasSuffix(name, "_HEAD") {
+ return true
+ }
+
+ switch name {
+ case "HEAD", "AUTO_MERGE", "BISECT_EXPECTED_REV", "NOTES_MERGE_PARTIAL", "NOTES_MERGE_REF", "MERGE_AUTOSTASH":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/ref/refname/root_syntax.go b/ref/refname/root_syntax.go
new file mode 100644
index 00000000..97a15cb9
--- /dev/null
+++ b/ref/refname/root_syntax.go
@@ -0,0 +1,13 @@
+package refname
+
+// IsRootSyntax reports whether name matches Git's all-caps root-ref syntax.
+func IsRootSyntax(name string) bool {
+ for i := range len(name) {
+ ch := name[i]
+ if (ch < 'A' || ch > 'Z') && ch != '-' && ch != '_' {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/ref/refname/safe.go b/ref/refname/safe.go
new file mode 100644
index 00000000..b36d3b2f
--- /dev/null
+++ b/ref/refname/safe.go
@@ -0,0 +1,31 @@
+package refname
+
+import "strings"
+
+// IsSafe reports whether name is one safe refname for direct filesystem
+// operations; see refname_is_safe.
+func IsSafe(name string) bool {
+ rest, ok := strings.CutPrefix(name, "refs/")
+ if ok {
+ if rest == "" || rest[0] == '/' || rest[len(rest)-1] == '/' {
+ return false
+ }
+
+ normalized, normOK := normalizeRefPath(rest)
+
+ return normOK && normalized == rest
+ }
+
+ if name == "" {
+ return false
+ }
+
+ for i := range len(name) {
+ ch := name[i]
+ if (ch < 'A' || ch > 'Z') && ch != '_' {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/ref/refname/sanitize.go b/ref/refname/sanitize.go
new file mode 100644
index 00000000..f543de7c
--- /dev/null
+++ b/ref/refname/sanitize.go
@@ -0,0 +1,19 @@
+package refname
+
+import (
+ "fmt"
+ "strings"
+)
+
+// SanitizeComponent mutates component until it satisfies
+// sanitize_refname_component.
+func SanitizeComponent(component string) string {
+ var builder strings.Builder
+
+ err := checkOrSanitizeRefname(component, refnameAllowOneLevel, &builder)
+ if err != nil {
+ panic(fmt.Sprintf("ref: sanitize component %q: %v", component, err))
+ }
+
+ return builder.String()
+}
diff --git a/ref/refname/slashes.go b/ref/refname/slashes.go
new file mode 100644
index 00000000..44d3e4ea
--- /dev/null
+++ b/ref/refname/slashes.go
@@ -0,0 +1,26 @@
+package refname
+
+import "strings"
+
+func collapseSlashes(name string) string {
+ if name == "" {
+ return ""
+ }
+
+ var builder strings.Builder
+ builder.Grow(len(name))
+
+ prev := byte('/')
+
+ for i := range len(name) {
+ ch := name[i]
+ if prev == '/' && ch == '/' {
+ continue
+ }
+
+ builder.WriteByte(ch)
+ prev = ch
+ }
+
+ return builder.String()
+}
diff --git a/ref/refname/tag.go b/ref/refname/tag.go
new file mode 100644
index 00000000..226c0fdd
--- /dev/null
+++ b/ref/refname/tag.go
@@ -0,0 +1,20 @@
+package refname
+
+import "strings"
+
+// Tag checks one tag shorthand and returns its fully-qualified
+// refs/tags/... name.
+func Tag(name string) (string, error) {
+ if strings.HasPrefix(name, "-") || name == "HEAD" {
+ return "", &NameError{Name: name, Reason: "invalid tag name"}
+ }
+
+ full := "refs/tags/" + name
+
+ err := validate(full, 0)
+ if err != nil {
+ return "", err
+ }
+
+ return full, nil
+}
diff --git a/ref/refname/update.go b/ref/refname/update.go
new file mode 100644
index 00000000..92830f1a
--- /dev/null
+++ b/ref/refname/update.go
@@ -0,0 +1,56 @@
+package refname
+
+import "strings"
+
+// ValidateUpdateName checks whether name is valid for one direct ref update.
+//
+// See transaction_refname_valid();
+// updates with a new OID use check_refname_format(..., ALLOW_ONELEVEL),
+// while delete/verify style operations use refname_is_safe().
+func ValidateUpdateName(name string, hasNewValue bool) error {
+ if IsPseudo(name) {
+ return &NameError{Name: name, Reason: "pseudoref updates are not allowed"}
+ }
+
+ if hasNewValue {
+ return Validate(name, Options{AllowOneLevel: true})
+ }
+
+ if !IsSafe(name) {
+ return &NameError{Name: name, Reason: "unsafe refname for update"}
+ }
+
+ return nil
+}
+
+// ValidateSymbolicTarget checks whether target is valid for one symref target.
+//
+// See refs_fsck_symref();
+// root refs are allowed directly, HEAD must point to refs/heads/...,
+// and non-root targets must be valid full refnames rooted at refs/ or
+// worktrees/.
+func ValidateSymbolicTarget(refname string, target string) error {
+ parsed := ParseWorktree(refname)
+ if parsed.BareRefName == "HEAD" && !strings.HasPrefix(target, "refs/heads/") {
+ return &NameError{Name: target, Reason: refname + " must point to refs/heads/..."}
+ }
+
+ if IsRoot(target) {
+ return nil
+ }
+
+ err := Validate(target, Options{})
+ if err != nil {
+ return err
+ }
+
+ if strings.HasPrefix(target, "refs/") {
+ return nil
+ }
+
+ if strings.HasPrefix(target, "worktrees/") {
+ return nil
+ }
+
+ return &NameError{Name: target, Reason: "symref target is not a ref"}
+}
diff --git a/ref/refname/utils.go b/ref/refname/utils.go
new file mode 100644
index 00000000..58944748
--- /dev/null
+++ b/ref/refname/utils.go
@@ -0,0 +1,22 @@
+package refname
+
+import (
+ "strings"
+)
+
+func overwriteLastByte(builder *strings.Builder, ch byte) {
+ overwriteBuilderAt(builder, builder.Len()-1, ch)
+}
+
+func overwriteBuilderAt(builder *strings.Builder, index int, ch byte) {
+ value := builder.String()
+ truncateBuilder(builder, index)
+ builder.WriteByte(ch)
+ builder.WriteString(value[index+1:])
+}
+
+func truncateBuilder(builder *strings.Builder, n int) {
+ value := builder.String()
+ builder.Reset()
+ builder.WriteString(value[:n])
+}
diff --git a/ref/refname/validate.go b/ref/refname/validate.go
new file mode 100644
index 00000000..1b8ad396
--- /dev/null
+++ b/ref/refname/validate.go
@@ -0,0 +1,65 @@
+package refname
+
+import "strings"
+
+// Validate checks whether name is one valid Git refname.
+func Validate(name string, options Options) error {
+ return validate(name, options.flags())
+}
+
+func validate(name string, flags int) error {
+ return checkOrSanitizeRefname(name, flags, nil)
+}
+
+func checkOrSanitizeRefname(name string, flags int, sanitized *strings.Builder) error {
+ componentCount := 0
+ remaining := name
+
+ if name == "@" {
+ if sanitized == nil {
+ return &NameError{Name: name, Reason: "single @ is not allowed"}
+ }
+
+ sanitized.WriteByte('-')
+ }
+
+ for {
+ if sanitized != nil && sanitized.Len() > 0 {
+ sanitized.WriteByte('/')
+ }
+
+ componentLen, err := checkRefnameComponent(remaining, &flags, sanitized, name)
+ switch {
+ case sanitized != nil && componentLen == 0:
+ case componentLen <= 0:
+ if err != nil {
+ return err
+ }
+
+ return &NameError{Name: name, Reason: "component has zero length"}
+ case err != nil:
+ return err
+ }
+
+ componentCount++
+
+ if componentLen == len(remaining) {
+ break
+ }
+
+ remaining = remaining[componentLen+1:]
+ }
+
+ componentLen := len(remaining)
+ if componentLen > 0 && remaining[componentLen-1] == '.' {
+ if sanitized == nil {
+ return &NameError{Name: name, Reason: "name ends with '.'"}
+ }
+ }
+
+ if flags&refnameAllowOneLevel == 0 && componentCount < 2 {
+ return &NameError{Name: name, Reason: "one-level refname is not allowed"}
+ }
+
+ return nil
+}
diff --git a/ref/refname/worktree.go b/ref/refname/worktree.go
new file mode 100644
index 00000000..48ca215d
--- /dev/null
+++ b/ref/refname/worktree.go
@@ -0,0 +1,75 @@
+package refname
+
+import "strings"
+
+// WorktreeType classifies one worktree-qualified refname prefix.
+type WorktreeType uint8
+
+const (
+ // WorktreeShared is one ordinary shared refname.
+ WorktreeShared WorktreeType = iota
+
+ // WorktreeCurrent is one current-worktree-only refname like HEAD or refs/worktree/...
+ WorktreeCurrent
+
+ // WorktreeMain is one main-worktree-qualified refname like main-worktree/HEAD.
+ WorktreeMain
+
+ // WorktreeOther is one other-worktree-qualified refname like worktrees/wt1/HEAD.
+ WorktreeOther
+)
+
+// IsPerWorktree reports whether name is one per-worktree ref namespace.
+func IsPerWorktree(name string) bool {
+ return strings.HasPrefix(name, "refs/worktree/") ||
+ strings.HasPrefix(name, "refs/bisect/") ||
+ strings.HasPrefix(name, "refs/rewritten/")
+}
+
+// ParsedWorktreeRef is the result of parsing one worktree-qualified refname.
+type ParsedWorktreeRef struct {
+ Type WorktreeType
+ WorktreeName string
+ BareRefName string
+}
+
+// ParseWorktree parses Git's worktree ref prefixes.
+func ParseWorktree(name string) ParsedWorktreeRef {
+ if bare, ok := strings.CutPrefix(name, "worktrees/"); ok {
+ worktreeName, rest, found := strings.Cut(bare, "/")
+ if !found {
+ return ParsedWorktreeRef{
+ Type: WorktreeOther,
+ WorktreeName: worktreeName,
+ BareRefName: "",
+ }
+ }
+
+ if isCurrentWorktreeRef(rest) {
+ return ParsedWorktreeRef{
+ Type: WorktreeOther,
+ WorktreeName: worktreeName,
+ BareRefName: rest,
+ }
+ }
+ }
+
+ if bare, ok := strings.CutPrefix(name, "main-worktree/"); ok && isCurrentWorktreeRef(bare) {
+ return ParsedWorktreeRef{
+ Type: WorktreeMain,
+ BareRefName: bare,
+ }
+ }
+
+ if isCurrentWorktreeRef(name) {
+ return ParsedWorktreeRef{
+ Type: WorktreeCurrent,
+ BareRefName: name,
+ }
+ }
+
+ return ParsedWorktreeRef{
+ Type: WorktreeShared,
+ BareRefName: name,
+ }
+}