264 lines
8.2 KiB
Go
264 lines
8.2 KiB
Go
// Package gitref reads git ref state directly from the on-disk .git
|
|
// directory, without invoking the git binary. The functions here are kept
|
|
// minimal and stdlib-only so they can be imported from both the task service
|
|
// and the sqlite migration layer (where adding a service dependency would
|
|
// invert the existing layering).
|
|
package gitref
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const headFile = "HEAD"
|
|
|
|
type defaultBranchSource uint8
|
|
|
|
const (
|
|
defaultBranchSourceRemote defaultBranchSource = iota + 1
|
|
defaultBranchSourceNamedFallback
|
|
defaultBranchSourceCurrentHEAD
|
|
)
|
|
|
|
// DefaultBranch returns the repository's *integration* branch — the branch
|
|
// that work is meant to merge back into. It is intentionally NOT the current
|
|
// HEAD: a developer who runs the dialog while checked out on a feature
|
|
// branch must still get "main" (or "master") back, otherwise downstream
|
|
// consumers (changes-panel merge-base, executor BaseBranch fallback) anchor
|
|
// to the wrong ref.
|
|
//
|
|
// Resolution order:
|
|
// 1. refs/remotes/origin/HEAD when set, except origin/HEAD=master yields to
|
|
// origin/main when it exists
|
|
// 2. origin/main
|
|
// 3. origin/master
|
|
// 4. local main
|
|
// 5. local master
|
|
// 6. The current HEAD as a last resort, so brand-new repos with only a
|
|
// feature branch still produce a value — callers that care about
|
|
// correctness can override.
|
|
func DefaultBranch(repoPath string) (string, error) {
|
|
branch, _, err := resolveDefaultBranch(repoPath)
|
|
return branch, err
|
|
}
|
|
|
|
func resolveDefaultBranch(repoPath string) (string, defaultBranchSource, error) {
|
|
safe, err := guardRepoPath(repoPath)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
gitDir, err := ResolveGitDir(safe)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
commonDir := ResolveCommonGitDir(gitDir)
|
|
commonDir, err = guardRepoPath(commonDir)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
if branch := readOriginHEAD(commonDir); branch != "" {
|
|
if branch == "master" {
|
|
if refExists(commonDir, "refs/remotes/origin/main") {
|
|
return "main", defaultBranchSourceRemote, nil
|
|
}
|
|
}
|
|
return branch, defaultBranchSourceRemote, nil
|
|
}
|
|
for _, candidate := range []struct {
|
|
ref string
|
|
branch string
|
|
}{
|
|
{"refs/remotes/origin/main", "main"},
|
|
{"refs/remotes/origin/master", "master"},
|
|
{"refs/heads/main", "main"},
|
|
{"refs/heads/master", "master"},
|
|
} {
|
|
if refExists(commonDir, candidate.ref) {
|
|
return candidate.branch, defaultBranchSourceNamedFallback, nil
|
|
}
|
|
}
|
|
branch, err := readHEADBranchFallback(gitDir)
|
|
return branch, defaultBranchSourceCurrentHEAD, err
|
|
}
|
|
|
|
// DefaultBranchOrEmpty returns DefaultBranch, but collapses detached HEAD's
|
|
// literal "HEAD" sentinel to empty for callers that persist branch names.
|
|
func DefaultBranchOrEmpty(repoPath string) (string, error) {
|
|
branch, source, err := resolveDefaultBranch(repoPath)
|
|
if err != nil || source == defaultBranchSourceCurrentHEAD {
|
|
return "", err
|
|
}
|
|
return branch, nil
|
|
}
|
|
|
|
// guardRepoPath rejects relative paths and any path containing `..` segments
|
|
// before passing the value into the file-reading helpers below. Service
|
|
// callers provide canonical explicit repository paths, but CodeQL's
|
|
// go/path-injection taint analysis does not trace that as a sanitizer, so we
|
|
// re-check here at the I/O boundary. The check is also genuinely useful: any
|
|
// caller - migration code, tests, or future callers - must hand us an absolute,
|
|
// traversal-free path or we refuse to read.
|
|
func guardRepoPath(repoPath string) (string, error) {
|
|
if repoPath == "" {
|
|
return "", fmt.Errorf("repository path is required")
|
|
}
|
|
if !filepath.IsAbs(repoPath) {
|
|
return "", fmt.Errorf("repository path must be absolute")
|
|
}
|
|
// Check the RAW input for '..' segments before cleaning. filepath.Clean
|
|
// resolves traversal away from absolute paths (`/allowed/../etc` →
|
|
// `/etc`), so iterating the cleaned path would silently pass attempted
|
|
// escapes. Splitting on both separators handles cross-platform inputs
|
|
// without depending on the OS-native separator.
|
|
for _, part := range strings.FieldsFunc(repoPath, func(r rune) bool {
|
|
return r == '/' || r == filepath.Separator
|
|
}) {
|
|
if part == ".." {
|
|
return "", fmt.Errorf("repository path must not contain '..' segments")
|
|
}
|
|
}
|
|
return filepath.Clean(repoPath), nil
|
|
}
|
|
|
|
// ResolveGitDir returns the actual git directory for repoPath, following
|
|
// `.git` files (worktree pointers) when present.
|
|
func ResolveGitDir(repoPath string) (string, error) {
|
|
gitPath := filepath.Join(repoPath, ".git")
|
|
info, err := os.Stat(gitPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if info.IsDir() {
|
|
return gitPath, nil
|
|
}
|
|
content, err := os.ReadFile(gitPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
line := strings.TrimSpace(string(content))
|
|
if !strings.HasPrefix(line, "gitdir:") {
|
|
return "", fmt.Errorf("invalid gitdir reference")
|
|
}
|
|
gitDir := strings.TrimSpace(strings.TrimPrefix(line, "gitdir:"))
|
|
if filepath.IsAbs(gitDir) {
|
|
return gitDir, nil
|
|
}
|
|
return filepath.Clean(filepath.Join(repoPath, gitDir)), nil
|
|
}
|
|
|
|
// ResolveCommonGitDir returns the shared git dir for a worktree, or gitDir
|
|
// itself for a regular repo. Refs (refs/heads/*, refs/remotes/*, packed-refs)
|
|
// live under the common dir, not the worktree's gitDir.
|
|
func ResolveCommonGitDir(gitDir string) string {
|
|
commonFile := filepath.Join(gitDir, "commondir")
|
|
content, err := os.ReadFile(commonFile)
|
|
if err != nil {
|
|
return gitDir
|
|
}
|
|
commonDir := strings.TrimSpace(string(content))
|
|
if commonDir == "" {
|
|
return gitDir
|
|
}
|
|
if filepath.IsAbs(commonDir) {
|
|
return filepath.Clean(commonDir)
|
|
}
|
|
return filepath.Clean(filepath.Join(gitDir, commonDir))
|
|
}
|
|
|
|
func readOriginHEAD(commonDir string) string {
|
|
headPath := filepath.Join(commonDir, "refs", "remotes", "origin", headFile)
|
|
content, err := os.ReadFile(headPath)
|
|
if err != nil {
|
|
// origin/HEAD only lives in packed-refs after a `git pack-refs --all`,
|
|
// and the on-disk symref format there is gnarly to parse correctly.
|
|
// We deliberately skip that case rather than maintain a broken parser:
|
|
// the named main/master fallbacks cover every realistic clone in
|
|
// practice.
|
|
return ""
|
|
}
|
|
return parseSymbolicRefToBranch(strings.TrimSpace(string(content)))
|
|
}
|
|
|
|
func parseSymbolicRefToBranch(line string) string {
|
|
ref, ok := strings.CutPrefix(line, "ref: ")
|
|
if !ok {
|
|
ref = line
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(ref, "refs/remotes/origin/"):
|
|
return strings.TrimPrefix(ref, "refs/remotes/origin/")
|
|
case strings.HasPrefix(ref, "refs/heads/"):
|
|
return strings.TrimPrefix(ref, "refs/heads/")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func refExists(commonDir, ref string) bool {
|
|
refPath, ok := safeRefPath(commonDir, ref)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if info, err := os.Stat(refPath); err == nil {
|
|
return !info.IsDir()
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(commonDir, "packed-refs"))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "^") {
|
|
continue
|
|
}
|
|
_, after, ok := strings.Cut(line, " ")
|
|
if !ok {
|
|
continue
|
|
}
|
|
if after == ref {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func safeRefPath(commonDir, ref string) (string, bool) {
|
|
if ref == "" || filepath.IsAbs(ref) {
|
|
return "", false
|
|
}
|
|
for _, part := range strings.FieldsFunc(ref, func(r rune) bool {
|
|
return r == '/' || r == filepath.Separator
|
|
}) {
|
|
if part == "" || part == "." || part == ".." {
|
|
return "", false
|
|
}
|
|
}
|
|
cleanCommon := filepath.Clean(commonDir)
|
|
candidate := filepath.Join(cleanCommon, filepath.Clean(ref))
|
|
rel, err := filepath.Rel(cleanCommon, candidate)
|
|
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
|
|
return "", false
|
|
}
|
|
return candidate, true
|
|
}
|
|
|
|
func readHEADBranchFallback(gitDir string) (string, error) {
|
|
headPath := filepath.Join(gitDir, headFile)
|
|
content, err := os.ReadFile(headPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
trimmed := strings.TrimSpace(string(content))
|
|
if ref, ok := strings.CutPrefix(trimmed, "ref: "); ok {
|
|
// Strip refs/heads/ as a prefix rather than splitting on "/" — branch
|
|
// names legally contain slashes (e.g. "feature/my-feature"), so taking
|
|
// the last path component would silently corrupt every nested branch.
|
|
return strings.TrimPrefix(ref, "refs/heads/"), nil
|
|
}
|
|
if trimmed != "" {
|
|
return headFile, nil
|
|
}
|
|
return "", fmt.Errorf("unable to determine branch")
|
|
}
|